From fdbd3f5fc93f3e91f48218f84ee1c37403858b8d Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Thu, 26 Mar 2026 16:51:17 +0000 Subject: [PATCH 1/5] feat: add Telegram bot for deploying on-chain trading agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram-native interface for deploying, monitoring, and controlling autonomous trading agents on Hyperliquid — like wallet bots but for agents. New module: tg_bot/ (12 files) - /start: wallet create/import with encrypted keystore - /deploy: strategy selection keyboard (18 strategies), instrument picker, risk presets (conservative/default/aggressive), mainnet double-confirm - /status /pause /resume /stop /balance: agent lifecycle control - /apex: multi-strategy APEX orchestration mode - NotifyingEngine: TradingEngine subclass pushing fills, PnL, risk alerts to Telegram via async queue with throttling - EngineBridge: thread-safe bridge (async bot <-> blocking engine thread) CLI: `hl telegram start [--mainnet] [--dry-run]` Deploy: RUN_MODE=telegram in Railway, Dockerfile installs telegram dep --- Dockerfile | 2 +- cli/commands/telegram_cmd.py | 58 +++++++ cli/main.py | 2 + pyproject.toml | 3 +- scripts/entrypoint.py | 8 +- tg_bot/__init__.py | 1 + tg_bot/auth.py | 64 +++++++ tg_bot/bot.py | 87 ++++++++++ tg_bot/config.py | 39 +++++ tg_bot/engine_bridge.py | 312 +++++++++++++++++++++++++++++++++++ tg_bot/formatters.py | 282 +++++++++++++++++++++++++++++++ tg_bot/handlers/__init__.py | 1 + tg_bot/handlers/apex.py | 118 +++++++++++++ tg_bot/handlers/control.py | 218 ++++++++++++++++++++++++ tg_bot/handlers/start.py | 194 ++++++++++++++++++++++ tg_bot/handlers/strategy.py | 267 ++++++++++++++++++++++++++++++ tg_bot/notifier.py | 134 +++++++++++++++ 17 files changed, 1787 insertions(+), 3 deletions(-) create mode 100644 cli/commands/telegram_cmd.py create mode 100644 tg_bot/__init__.py create mode 100644 tg_bot/auth.py create mode 100644 tg_bot/bot.py create mode 100644 tg_bot/config.py create mode 100644 tg_bot/engine_bridge.py create mode 100644 tg_bot/formatters.py create mode 100644 tg_bot/handlers/__init__.py create mode 100644 tg_bot/handlers/apex.py create mode 100644 tg_bot/handlers/control.py create mode 100644 tg_bot/handlers/start.py create mode 100644 tg_bot/handlers/strategy.py create mode 100644 tg_bot/notifier.py diff --git a/Dockerfile b/Dockerfile index 689eb70..b1f9495 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ RUN apt-get update \ WORKDIR /app COPY . . -RUN pip install --no-cache-dir -e ".[mcp]" +RUN pip install --no-cache-dir -e ".[mcp,telegram]" # Persistent state volume (Railway mounts here) RUN mkdir -p /data diff --git a/cli/commands/telegram_cmd.py b/cli/commands/telegram_cmd.py new file mode 100644 index 0000000..9530707 --- /dev/null +++ b/cli/commands/telegram_cmd.py @@ -0,0 +1,58 @@ +"""hl telegram — start the Telegram bot interface.""" +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +import typer + +telegram_app = typer.Typer() + + +@telegram_app.command("start") +def telegram_start( + mainnet: bool = typer.Option( + False, "--mainnet", + help="Connect to mainnet (default: testnet)", + ), + dry_run: bool = typer.Option( + False, "--dry-run", + help="Agents run in dry-run mode (no real orders)", + ), +): + """Start the Telegram bot for deploying and controlling trading agents.""" + 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.INFO, + format="%(asctime)s %(name)-14s %(levelname)-5s %(message)s", + datefmt="%H:%M:%S", + ) + + from tg_bot.config import TelegramBotConfig + from tg_bot.bot import run_bot + + config = TelegramBotConfig.from_env() + + if mainnet: + config.default_network = "mainnet" + + if not config.bot_token: + typer.echo( + "ERROR: TELEGRAM_BOT_TOKEN not set.\n" + "1. Create a bot via @BotFather on Telegram\n" + "2. Set TELEGRAM_BOT_TOKEN= in your environment\n" + "3. Run this command again", + err=True, + ) + raise typer.Exit(code=1) + + typer.echo(f"Network: {config.default_network}") + typer.echo(f"Chat IDs: {config.allowed_chat_ids or 'auto-detect on first /start'}") + typer.echo("Bot starting... (Ctrl+C to stop)") + typer.echo("") + + run_bot(config) diff --git a/cli/main.py b/cli/main.py index 6253f07..e67b7dc 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.telegram_cmd import telegram_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(telegram_app, name="telegram", help="Telegram bot — deploy agents from chat") def main(): diff --git a/pyproject.toml b/pyproject.toml index 0b55b07..ae08f9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ [project.optional-dependencies] llm = ["anthropic>=0.40.0"] mcp = ["mcp>=1.0.0"] +telegram = ["python-telegram-bot>=21.0"] dev = ["pytest>=7.0", "ruff>=0.4.0", "mypy>=1.8.0"] [project.scripts] @@ -55,4 +56,4 @@ disallow_untyped_defs = false ignore_missing_imports = true [tool.setuptools.packages.find] -include = ["cli*", "strategies*", "sdk*", "common*", "parent*", "modules*", "skills*", "quoting_engine*"] +include = ["cli*", "strategies*", "sdk*", "common*", "parent*", "modules*", "skills*", "quoting_engine*", "tg_bot*"] diff --git a/scripts/entrypoint.py b/scripts/entrypoint.py index 83839c5..c333346 100644 --- a/scripts/entrypoint.py +++ b/scripts/entrypoint.py @@ -306,8 +306,14 @@ def build_command() -> list[str]: elif mode == "mcp": return py + ["mcp", "serve", "--transport", "sse"] + elif mode == "telegram": + cmd = py + ["telegram", "start"] + if os.environ.get("HL_TESTNET", "true").lower() == "false": + cmd.append("--mainnet") + return cmd + else: - log.error("Unknown RUN_MODE: %s. Use apex, wolf, strategy, or mcp.", mode) + log.error("Unknown RUN_MODE: %s. Use apex, wolf, strategy, mcp, or telegram.", mode) sys.exit(1) diff --git a/tg_bot/__init__.py b/tg_bot/__init__.py new file mode 100644 index 0000000..28edfb8 --- /dev/null +++ b/tg_bot/__init__.py @@ -0,0 +1 @@ +"""Telegram bot interface for deploying and controlling on-chain trading agents.""" diff --git a/tg_bot/auth.py b/tg_bot/auth.py new file mode 100644 index 0000000..d6c6796 --- /dev/null +++ b/tg_bot/auth.py @@ -0,0 +1,64 @@ +"""Single-user authentication for Telegram bot.""" +from __future__ import annotations + +import logging +from functools import wraps +from typing import Callable + +from telegram import Update +from telegram.ext import ContextTypes + +log = logging.getLogger("telegram.auth") + +# File to persist auto-detected chat ID +_CHAT_ID_FILE = None + + +def _get_chat_id_file(): + from pathlib import Path + return Path.home() / ".hl-agent" / "telegram_chat_id" + + +def load_persisted_chat_id() -> int | None: + """Load previously persisted chat ID from disk.""" + path = _get_chat_id_file() + if path.exists(): + try: + return int(path.read_text().strip()) + except (ValueError, OSError): + pass + return None + + +def persist_chat_id(chat_id: int) -> None: + """Save chat ID to disk for persistence across restarts.""" + path = _get_chat_id_file() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(chat_id)) + + +def authorized(func: Callable) -> Callable: + """Decorator that restricts handler to allowed chat IDs. + + On first interaction, if no chat IDs are configured, auto-registers the first user. + """ + @wraps(func) + async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE): + chat_id = update.effective_chat.id + allowed: list = context.bot_data.get("allowed_chat_ids", []) + + if not allowed: + # Auto-register first user + allowed.append(chat_id) + context.bot_data["allowed_chat_ids"] = allowed + persist_chat_id(chat_id) + log.info("Auto-registered chat ID %d as authorized user", chat_id) + + if chat_id not in allowed: + log.warning("Unauthorized access attempt from chat_id=%d", chat_id) + await update.message.reply_text("Unauthorized. This bot is private.") + return + + return await func(update, context) + + return wrapper diff --git a/tg_bot/bot.py b/tg_bot/bot.py new file mode 100644 index 0000000..fe46bab --- /dev/null +++ b/tg_bot/bot.py @@ -0,0 +1,87 @@ +"""Telegram bot application — entry point and handler registration.""" +from __future__ import annotations + +import asyncio +import logging + +from telegram.ext import Application + +from tg_bot.auth import load_persisted_chat_id +from tg_bot.config import TelegramBotConfig +from tg_bot.engine_bridge import EngineBridge +from tg_bot.handlers.apex import register_apex_handlers +from tg_bot.handlers.control import register_control_handlers +from tg_bot.handlers.start import build_start_handler +from tg_bot.handlers.strategy import build_deploy_handler +from tg_bot.notifier import Notifier + +log = logging.getLogger("telegram.bot") + + +async def post_init(application: Application) -> None: + """Called after bot is initialized but before polling starts.""" + config: TelegramBotConfig = application.bot_data["config"] + event_queue: asyncio.Queue = application.bot_data["event_queue"] + + # Auto-detect chat ID for notifications + chat_id = None + if config.allowed_chat_ids: + chat_id = config.allowed_chat_ids[0] + else: + persisted = load_persisted_chat_id() + if persisted: + config.allowed_chat_ids.append(persisted) + chat_id = persisted + + if chat_id: + notifier = Notifier( + bot=application.bot, + chat_id=chat_id, + event_queue=event_queue, + pnl_interval_s=config.notification_interval_s, + tick_summary_interval_s=config.tick_summary_interval_s, + ) + notifier.start() + application.bot_data["notifier"] = notifier + log.info("Notifier started for chat_id=%d", chat_id) + else: + log.info("No chat ID configured — notifier will start after /start") + + +def run_bot(config: TelegramBotConfig) -> None: + """Build and run the Telegram bot (blocking).""" + if not config.bot_token: + raise RuntimeError("TELEGRAM_BOT_TOKEN is required. Set it in your environment.") + + log.info("Starting Telegram bot (network=%s)", config.default_network) + + # Create event queue for engine -> bot communication + event_queue = asyncio.Queue() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # Build application + application = ( + Application.builder() + .token(config.bot_token) + .post_init(post_init) + .build() + ) + + # Store shared state + application.bot_data["config"] = config + application.bot_data["event_queue"] = event_queue + application.bot_data["allowed_chat_ids"] = list(config.allowed_chat_ids) + + # Create engine bridge + bridge = EngineBridge(event_queue=event_queue, loop=loop) + application.bot_data["engine_bridge"] = bridge + + # Register handlers (order matters — ConversationHandlers first) + application.add_handler(build_start_handler()) + application.add_handler(build_deploy_handler()) + register_control_handlers(application) + register_apex_handlers(application) + + log.info("Bot ready — polling for updates") + application.run_polling(drop_pending_updates=True) diff --git a/tg_bot/config.py b/tg_bot/config.py new file mode 100644 index 0000000..9ab596c --- /dev/null +++ b/tg_bot/config.py @@ -0,0 +1,39 @@ +"""Telegram bot configuration.""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class TelegramBotConfig: + """Configuration for the Telegram trading bot.""" + + bot_token: str = "" + allowed_chat_ids: List[int] = field(default_factory=list) + default_network: str = "testnet" + mainnet_confirmation: bool = True + notification_interval_s: int = 60 + tick_summary_interval_s: int = 300 + max_concurrent_agents: int = 1 + + @classmethod + def from_env(cls) -> "TelegramBotConfig": + token = os.environ.get("TELEGRAM_BOT_TOKEN", "") + chat_ids_raw = os.environ.get("TELEGRAM_CHAT_ID", "") + chat_ids = [] + if chat_ids_raw: + chat_ids = [int(x.strip()) for x in chat_ids_raw.split(",") if x.strip()] + + network = "mainnet" if os.environ.get("HL_TESTNET", "true").lower() == "false" else "testnet" + + return cls( + bot_token=token, + allowed_chat_ids=chat_ids, + default_network=network, + ) + + @property + def is_mainnet(self) -> bool: + return self.default_network == "mainnet" diff --git a/tg_bot/engine_bridge.py b/tg_bot/engine_bridge.py new file mode 100644 index 0000000..fde7263 --- /dev/null +++ b/tg_bot/engine_bridge.py @@ -0,0 +1,312 @@ +"""Thread-safe bridge between Telegram bot and TradingEngine.""" +from __future__ import annotations + +import asyncio +import logging +import os +import signal +import threading +import time +from decimal import Decimal +from typing import Any, Dict, Optional + +from cli.engine import TradingEngine + +log = logging.getLogger("telegram.bridge") +ZERO = Decimal("0") + + +class NotifyingEngine(TradingEngine): + """TradingEngine subclass that pushes events to an asyncio queue.""" + + def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop, **kwargs): + super().__init__(**kwargs) + self._event_queue = event_queue + self._loop = loop + + def _push_event(self, event: Dict[str, Any]) -> None: + """Thread-safe push from engine thread to async queue.""" + try: + self._loop.call_soon_threadsafe(self._event_queue.put_nowait, event) + except Exception: + pass # Don't crash engine if notification fails + + def _log_tick(self, snapshot, decisions, fills, ok: bool) -> None: + """Override to also push tick data to Telegram.""" + super()._log_tick(snapshot, decisions, fills, ok) + + agent_id = self.strategy.strategy_id + pos = self.position_tracker.get_agent_position(agent_id, self.instrument) + mid_dec = Decimal(str(snapshot.mid_price)) + + # Push fills as individual events + for fill in fills: + self._push_event({ + "type": "fill", + "side": fill.side, + "quantity": str(fill.quantity), + "price": str(fill.price), + "instrument": fill.instrument, + "strategy": self.strategy.strategy_id, + "tick": self.tick_count, + }) + + # Push tick summary + self._push_event({ + "type": "tick", + "tick_count": self.tick_count, + "instrument": self.instrument, + "strategy": self.strategy.strategy_id, + "mid_price": snapshot.mid_price, + "pos_qty": float(pos.net_qty), + "avg_entry": float(pos.avg_entry_price), + "upnl": float(pos.unrealized_pnl(mid_dec)), + "rpnl": float(pos.realized_pnl), + "orders_sent": len(decisions), + "orders_filled": len(fills), + "risk_ok": ok, + "reduce_only": self.risk_manager.state.reduce_only, + "safe_mode": self.risk_manager.state.safe_mode, + }) + + def _handle_shutdown(self, signum, frame): + """Override to push shutdown event instead of setting signal handler.""" + log.info("Engine shutdown signal received") + self._running = False + + def _shutdown(self): + """Override to push shutdown summary.""" + super()._shutdown() + + agent_id = self.strategy.strategy_id + pos = self.position_tracker.get_agent_position(agent_id, self.instrument) + elapsed = (time.time() * 1000 - self.start_time_ms) / 1000 + + try: + snap = self.hl.get_snapshot(self.instrument) + mid = Decimal(str(snap.mid_price)) if snap.mid_price > 0 else pos.avg_entry_price + except Exception: + mid = pos.avg_entry_price + + stats = self.order_manager.stats + self._push_event({ + "type": "shutdown", + "tick_count": self.tick_count, + "total_placed": stats["total_placed"], + "total_filled": stats["total_filled"], + "total_pnl": float(pos.total_pnl(mid)), + "elapsed_s": elapsed, + }) + + +class EngineBridge: + """Manages TradingEngine lifecycle from async Telegram context.""" + + def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop): + self.event_queue = event_queue + self.loop = loop + self.engine: Optional[NotifyingEngine] = None + self.engine_thread: Optional[threading.Thread] = None + self._paused = False + + def is_running(self) -> bool: + return ( + self.engine is not None + and self.engine._running + and self.engine_thread is not None + and self.engine_thread.is_alive() + ) + + def start_agent( + self, + strategy_name: str, + instrument: str, + mainnet: bool = False, + risk_overrides: Optional[Dict[str, Any]] = None, + dry_run: bool = False, + mock: bool = False, + ) -> Dict[str, Any]: + """Start a trading agent in a background thread.""" + if self.is_running(): + raise RuntimeError("Agent is already running. Stop it first.") + + import sys + from pathlib import Path + + project_root = str(Path(__file__).resolve().parent.parent) + if project_root not in sys.path: + sys.path.insert(0, project_root) + + from cli.config import TradingConfig + from cli.strategy_registry import resolve_instrument, resolve_strategy_path + from sdk.strategy_sdk.loader import load_strategy + + # Build config + cfg = TradingConfig() + cfg.strategy = strategy_name + cfg.instrument = resolve_instrument(instrument) + cfg.mainnet = mainnet + cfg.dry_run = dry_run + + if risk_overrides: + for key, val in risk_overrides.items(): + if hasattr(cfg, key): + setattr(cfg, key, val) + + # Network guard + if mainnet: + env_testnet = os.environ.get("HL_TESTNET", "true").lower() + if env_testnet == "true": + raise RuntimeError( + "Cannot deploy on mainnet: HL_TESTNET=true in environment. " + "Set HL_TESTNET=false first." + ) + + # Resolve strategy + strategy_path = resolve_strategy_path(cfg.strategy) + strategy_cls = load_strategy(strategy_path) + strategy_instance = strategy_cls(strategy_id=cfg.strategy, **dict(cfg.strategy_params)) + + # Build HL adapter + if mock or dry_run: + from cli.hl_adapter import DirectMockProxy + hl = DirectMockProxy() + else: + from cli.hl_adapter import DirectHLProxy + from parent.hl_proxy import HLProxy + + private_key = cfg.get_private_key() + raw_hl = HLProxy(private_key=private_key, testnet=not cfg.mainnet) + hl = DirectHLProxy(raw_hl) + + # Builder fee + builder_cfg = cfg.get_builder_config() + builder_info = builder_cfg.to_builder_info() + + # Create engine + self.engine = NotifyingEngine( + event_queue=self.event_queue, + loop=self.loop, + hl=hl, + strategy=strategy_instance, + instrument=cfg.instrument, + tick_interval=cfg.tick_interval, + dry_run=cfg.dry_run, + data_dir=cfg.data_dir, + risk_limits=cfg.to_risk_limits(), + builder=builder_info, + ) + + # Start in background thread + self.engine_thread = threading.Thread( + target=self._run_engine, + name="trading-engine", + daemon=True, + ) + self.engine_thread.start() + self._paused = False + + log.info("Agent started: strategy=%s instrument=%s mainnet=%s", + strategy_name, instrument, mainnet) + return {"status": "started", "strategy": strategy_name, "instrument": instrument} + + def _run_engine(self) -> None: + """Engine thread entry point.""" + try: + self.engine.run(resume=True) + except Exception as e: + log.error("Engine crashed: %s", e, exc_info=True) + self.engine._push_event({ + "type": "error", + "message": f"Engine crashed: {e}", + }) + + def stop_agent(self) -> Dict[str, Any]: + """Stop the running agent. Returns shutdown summary.""" + if not self.engine: + return {"status": "not_running"} + + self.engine._running = False + + if self.engine_thread and self.engine_thread.is_alive(): + self.engine_thread.join(timeout=30) + + stats = self.engine.order_manager.stats if self.engine else {} + agent_id = self.engine.strategy.strategy_id if self.engine else "unknown" + elapsed = (time.time() * 1000 - self.engine.start_time_ms) / 1000 if self.engine else 0 + + # Get final PnL + total_pnl = 0.0 + if self.engine: + pos = self.engine.position_tracker.get_agent_position(agent_id, self.engine.instrument) + try: + snap = self.engine.hl.get_snapshot(self.engine.instrument) + mid = Decimal(str(snap.mid_price)) + except Exception: + mid = pos.avg_entry_price + total_pnl = float(pos.total_pnl(mid)) + + result = { + "status": "stopped", + "tick_count": self.engine.tick_count if self.engine else 0, + "total_placed": stats.get("total_placed", 0), + "total_filled": stats.get("total_filled", 0), + "total_pnl": total_pnl, + "elapsed_s": elapsed, + } + + self.engine = None + self.engine_thread = None + return result + + def pause_agent(self) -> None: + """Pause the engine (stops ticking but keeps state).""" + if self.engine: + self.engine._running = False + self._paused = True + + def resume_agent(self) -> None: + """Resume from paused state.""" + if not self.engine or not self._paused: + raise RuntimeError("No paused agent to resume") + + self.engine_thread = threading.Thread( + target=self._run_engine, + name="trading-engine", + daemon=True, + ) + self.engine._running = True + self.engine_thread.start() + self._paused = False + + def get_status(self) -> Dict[str, Any]: + """Get current engine status (thread-safe read of engine state).""" + if not self.engine: + return {"running": False} + + agent_id = self.engine.strategy.strategy_id + pos = self.engine.position_tracker.get_agent_position(agent_id, self.engine.instrument) + mid_dec = Decimal(str(1.0)) + + try: + snap = self.engine.hl.get_snapshot(self.engine.instrument) + mid_dec = Decimal(str(snap.mid_price)) + except Exception: + mid_dec = pos.avg_entry_price if pos.avg_entry_price > 0 else Decimal("1") + + elapsed = (time.time() * 1000 - self.engine.start_time_ms) / 1000 + + return { + "running": self.engine._running, + "strategy": agent_id, + "instrument": self.engine.instrument, + "tick_count": self.engine.tick_count, + "pos_qty": float(pos.net_qty), + "avg_entry": float(pos.avg_entry_price), + "upnl": float(pos.unrealized_pnl(mid_dec)), + "rpnl": float(pos.realized_pnl), + "elapsed_s": elapsed, + "risk_ok": self.engine.risk_manager.can_trade(), + "reduce_only": self.engine.risk_manager.state.reduce_only, + "safe_mode": self.engine.risk_manager.state.safe_mode, + } diff --git a/tg_bot/formatters.py b/tg_bot/formatters.py new file mode 100644 index 0000000..6f5759b --- /dev/null +++ b/tg_bot/formatters.py @@ -0,0 +1,282 @@ +"""Telegram message formatting — plain text cards and inline keyboards.""" +from __future__ import annotations + +import time +from typing import Any, Dict, List, Optional + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + + +def escape_md(text: str) -> str: + """Escape special characters for MarkdownV2.""" + special = r"_*[]()~`>#+-=|{}.!" + for ch in special: + text = text.replace(ch, f"\\{ch}") + return text + + +# ── Inline Keyboards ── + + +def wallet_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([ + [ + InlineKeyboardButton("Create New Wallet", callback_data="wallet_create"), + InlineKeyboardButton("Import Private Key", callback_data="wallet_import"), + ], + ]) + + +def strategy_keyboard(strategies: Dict[str, Dict[str, Any]], page: int = 0, per_page: int = 8) -> InlineKeyboardMarkup: + """Paginated strategy selection keyboard.""" + names = sorted(strategies.keys()) + total_pages = (len(names) + per_page - 1) // per_page + start = page * per_page + page_items = names[start:start + per_page] + + rows = [] + for i in range(0, len(page_items), 2): + row = [InlineKeyboardButton(name, callback_data=f"strat_{name}") for name in page_items[i:i + 2]] + rows.append(row) + + # Pagination buttons + nav = [] + if page > 0: + nav.append(InlineKeyboardButton("<< Prev", callback_data=f"strat_page_{page - 1}")) + if page < total_pages - 1: + nav.append(InlineKeyboardButton("Next >>", callback_data=f"strat_page_{page + 1}")) + if nav: + rows.append(nav) + + return InlineKeyboardMarkup(rows) + + +def instrument_keyboard() -> InlineKeyboardMarkup: + """Common instruments + YEX markets.""" + return InlineKeyboardMarkup([ + [ + InlineKeyboardButton("ETH-PERP", callback_data="inst_ETH-PERP"), + InlineKeyboardButton("BTC-PERP", callback_data="inst_BTC-PERP"), + ], + [ + InlineKeyboardButton("SOL-PERP", callback_data="inst_SOL-PERP"), + InlineKeyboardButton("HYPE-PERP", callback_data="inst_HYPE-PERP"), + ], + [ + InlineKeyboardButton("VXX-USDYP", callback_data="inst_VXX-USDYP"), + InlineKeyboardButton("US3M-USDYP", callback_data="inst_US3M-USDYP"), + ], + [ + InlineKeyboardButton("BTCSWP-USDYP", callback_data="inst_BTCSWP-USDYP"), + ], + ]) + + +def preset_keyboard() -> InlineKeyboardMarkup: + """Risk preset selection.""" + return InlineKeyboardMarkup([ + [ + InlineKeyboardButton("Conservative", callback_data="preset_conservative"), + InlineKeyboardButton("Default", callback_data="preset_default"), + InlineKeyboardButton("Aggressive", callback_data="preset_aggressive"), + ], + ]) + + +def confirm_keyboard(mainnet: bool = False) -> InlineKeyboardMarkup: + """Deployment confirmation.""" + rows = [ + [ + InlineKeyboardButton("Deploy Agent", callback_data="confirm_deploy"), + InlineKeyboardButton("Cancel", callback_data="confirm_cancel"), + ], + ] + return InlineKeyboardMarkup(rows) + + +def mainnet_confirm_keyboard() -> InlineKeyboardMarkup: + """Double confirmation for mainnet.""" + return InlineKeyboardMarkup([ + [ + InlineKeyboardButton( + "YES - Deploy on MAINNET with REAL funds", + callback_data="mainnet_confirm_yes", + ), + ], + [ + InlineKeyboardButton("Cancel", callback_data="mainnet_confirm_no"), + ], + ]) + + +def control_keyboard() -> InlineKeyboardMarkup: + """Agent control buttons.""" + return InlineKeyboardMarkup([ + [ + InlineKeyboardButton("Pause", callback_data="ctrl_pause"), + InlineKeyboardButton("Resume", callback_data="ctrl_resume"), + InlineKeyboardButton("Stop", callback_data="ctrl_stop"), + ], + [ + InlineKeyboardButton("Status", callback_data="ctrl_status"), + InlineKeyboardButton("Balance", callback_data="ctrl_balance"), + ], + ]) + + +# ── Message Cards ── + + +def welcome_card(has_wallet: bool, address: str = "", balance: float = 0.0) -> str: + if has_wallet: + return ( + "Nunchi Trading Agent\n" + "━━━━━━━━━━━━━━━━━━━━\n" + f"Wallet: {address[:8]}...{address[-6:]}\n" + f"Balance: ${balance:.2f}\n\n" + "Commands:\n" + "/deploy - Deploy a trading agent\n" + "/status - Check agent status\n" + "/balance - Account balance\n" + "/stop - Stop running agent\n" + "/help - All commands" + ) + return ( + "Nunchi Trading Agent\n" + "━━━━━━━━━━━━━━━━━━━━\n" + "Deploy autonomous trading agents on Hyperliquid\n" + "directly from Telegram.\n\n" + "First, let's set up your wallet." + ) + + +def wallet_created_card(address: str, network: str) -> str: + return ( + "Wallet Created\n" + "━━━━━━━━━━━━━━━━━━━━\n" + f"Address: {address}\n" + f"Network: {network}\n\n" + f"{'Claim testnet USDyP: /claim' if network == 'testnet' else 'Deposit USDC via Hyperliquid web UI'}\n\n" + "Your key is encrypted and stored locally.\n" + "Use /deploy to start a trading agent." + ) + + +def strategy_info_card(name: str, info: Dict[str, Any]) -> str: + params = "\n".join(f" {k}: {v}" for k, v in info.get("params", {}).items()) + return ( + f"Strategy: {name}\n" + f"━━━━━━━━━━━━━━━━━━━━\n" + f"{info['description']}\n\n" + f"Default Parameters:\n{params}" + ) + + +def deploy_confirm_card( + strategy: str, + instrument: str, + preset: str, + network: str, + risk_params: Dict[str, Any], +) -> str: + return ( + "Deploy Confirmation\n" + "━━━━━━━━━━━━━━━━━━━━\n" + f"Strategy: {strategy}\n" + f"Instrument: {instrument}\n" + f"Preset: {preset}\n" + f"Network: {network.upper()}\n\n" + f"Risk Limits:\n" + f" Max Position: {risk_params.get('max_position_qty', 'default')}\n" + f" Max Notional: ${risk_params.get('max_notional_usd', 'default')}\n" + f" Max Leverage: {risk_params.get('max_leverage', 'default')}x\n" + ) + + +def fill_card( + side: str, + quantity: str, + price: str, + instrument: str, + strategy: str, + tick: int, +) -> str: + direction = "BUY" if side == "buy" else "SELL" + return ( + f"Fill: {direction} {quantity} {instrument} @ ${price}\n" + f"Strategy: {strategy} | Tick: {tick}" + ) + + +def status_card( + strategy: str, + instrument: str, + network: str, + tick_count: int, + pos_qty: float, + avg_entry: float, + upnl: float, + rpnl: float, + elapsed_s: float, + risk_ok: bool, +) -> str: + total_pnl = upnl + rpnl + sign = lambda v: f"+{v:.2f}" if v >= 0 else f"{v:.2f}" + elapsed_min = int(elapsed_s // 60) + + return ( + "Agent Status\n" + "━━━━━━━━━━━━━━━━━━━━\n" + f"Strategy: {strategy}\n" + f"Instrument: {instrument}\n" + f"Network: {network}\n" + f"Ticks: {tick_count} ({elapsed_min}min)\n\n" + f"Position: {sign(pos_qty)} @ ${avg_entry:.4f}\n" + f"PnL: uPnL ${sign(upnl)} | rPnL ${sign(rpnl)} | Total ${sign(total_pnl)}\n" + f"Risk: {'OK' if risk_ok else 'BLOCKED'}" + ) + + +def shutdown_card( + tick_count: int, + total_placed: int, + total_filled: int, + total_pnl: float, + elapsed_s: float, +) -> str: + sign = lambda v: f"+{v:.2f}" if v >= 0 else f"{v:.2f}" + return ( + "Agent Stopped\n" + "━━━━━━━━━━━━━━━━━━━━\n" + f"Ticks: {tick_count}\n" + f"Orders: {total_placed} placed, {total_filled} filled\n" + f"PnL: ${sign(total_pnl)}\n" + f"Runtime: {int(elapsed_s)}s" + ) + + +def balance_card(address: str, balance: float, network: str) -> str: + return ( + "Account Balance\n" + "━━━━━━━━━━━━━━━━━━━━\n" + f"Address: {address[:8]}...{address[-6:]}\n" + f"Balance: ${balance:.2f}\n" + f"Network: {network}" + ) + + +def help_card() -> str: + return ( + "Nunchi Bot Commands\n" + "━━━━━━━━━━━━━━━━━━━━\n" + "/start - Setup wallet\n" + "/deploy - Deploy trading agent\n" + "/status - Agent status + PnL\n" + "/balance - Account balance\n" + "/pause - Pause agent\n" + "/resume - Resume agent\n" + "/stop - Stop agent\n" + "/switch - Change strategy\n" + "/apex - APEX multi-strategy mode\n" + "/help - This message" + ) diff --git a/tg_bot/handlers/__init__.py b/tg_bot/handlers/__init__.py new file mode 100644 index 0000000..28ec799 --- /dev/null +++ b/tg_bot/handlers/__init__.py @@ -0,0 +1 @@ +"""Telegram bot command handlers.""" diff --git a/tg_bot/handlers/apex.py b/tg_bot/handlers/apex.py new file mode 100644 index 0000000..3d9803e --- /dev/null +++ b/tg_bot/handlers/apex.py @@ -0,0 +1,118 @@ +"""APEX multi-strategy orchestration mode via Telegram.""" +from __future__ import annotations + +import logging +import subprocess +import sys +from pathlib import Path + +from telegram import Update +from telegram.ext import CommandHandler, ContextTypes + +from tg_bot.auth import authorized + +log = logging.getLogger("tg_bot.apex") + +# Track the APEX subprocess +_apex_proc: subprocess.Popen | None = None + + +@authorized +async def apex_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Start APEX multi-slot orchestration.""" + global _apex_proc + + if _apex_proc and _apex_proc.poll() is None: + await update.message.reply_text( + "APEX is already running.\n" + "Use /apex_stop to stop it, or /apex_status to check." + ) + return + + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + project_root = str(Path(__file__).resolve().parent.parent.parent) + cmd = [sys.executable, "-m", "cli.main", "apex", "run"] + if network == "mainnet": + cmd.append("--mainnet") + + try: + _apex_proc = subprocess.Popen( + cmd, + cwd=project_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + await update.message.reply_text( + f"APEX started (pid={_apex_proc.pid})\n" + f"Network: {network}\n\n" + "Use /apex_status to check, /apex_stop to stop." + ) + log.info("APEX started (pid=%d, network=%s)", _apex_proc.pid, network) + except Exception as e: + log.error("Failed to start APEX: %s", e) + await update.message.reply_text(f"Failed to start APEX: {e}") + + +@authorized +async def apex_status_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Check APEX status.""" + global _apex_proc + + if not _apex_proc: + await update.message.reply_text("APEX is not running. Use /apex to start.") + return + + rc = _apex_proc.poll() + if rc is not None: + await update.message.reply_text(f"APEX exited with code {rc}. Use /apex to restart.") + _apex_proc = None + return + + # Try to get status from CLI + project_root = str(Path(__file__).resolve().parent.parent.parent) + try: + result = subprocess.run( + [sys.executable, "-m", "cli.main", "apex", "status"], + cwd=project_root, + capture_output=True, + text=True, + timeout=10, + ) + # Strip ANSI codes for Telegram + import re + clean = re.sub(r'\033\[[0-9;]*m', '', result.stdout) + await update.message.reply_text(clean[:4000] if clean else "APEX running (no status output)") + except Exception as e: + await update.message.reply_text(f"APEX running (pid={_apex_proc.pid}), status check failed: {e}") + + +@authorized +async def apex_stop_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Stop APEX.""" + global _apex_proc + + if not _apex_proc or _apex_proc.poll() is not None: + await update.message.reply_text("APEX is not running.") + _apex_proc = None + return + + import signal + _apex_proc.send_signal(signal.SIGTERM) + try: + _apex_proc.wait(timeout=15) + await update.message.reply_text("APEX stopped.") + except subprocess.TimeoutExpired: + _apex_proc.kill() + await update.message.reply_text("APEX killed (did not stop gracefully).") + + _apex_proc = None + + +def register_apex_handlers(app) -> None: + """Register APEX command handlers.""" + app.add_handler(CommandHandler("apex", apex_cmd)) + app.add_handler(CommandHandler("apex_status", apex_status_cmd)) + app.add_handler(CommandHandler("apex_stop", apex_stop_cmd)) diff --git a/tg_bot/handlers/control.py b/tg_bot/handlers/control.py new file mode 100644 index 0000000..48ca31e --- /dev/null +++ b/tg_bot/handlers/control.py @@ -0,0 +1,218 @@ +"""Agent control commands — status, pause, resume, stop, balance.""" +from __future__ import annotations + +import logging +import time + +from telegram import Update +from telegram.ext import CallbackQueryHandler, CommandHandler, ContextTypes + +from tg_bot.auth import authorized +from tg_bot.formatters import balance_card, control_keyboard, help_card, status_card, shutdown_card + +log = logging.getLogger("telegram.control") + + +def _get_bridge(context): + return context.bot_data.get("engine_bridge") + + +@authorized +async def status_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show current agent status.""" + bridge = _get_bridge(context) + if not bridge or not bridge.is_running(): + await update.message.reply_text("No agent is running. Use /deploy to start one.") + return + + info = bridge.get_status() + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + text = status_card( + strategy=info["strategy"], + instrument=info["instrument"], + network=network, + tick_count=info["tick_count"], + pos_qty=info["pos_qty"], + avg_entry=info["avg_entry"], + upnl=info["upnl"], + rpnl=info["rpnl"], + elapsed_s=info["elapsed_s"], + risk_ok=info["risk_ok"], + ) + await update.message.reply_text(text, reply_markup=control_keyboard()) + + +@authorized +async def pause_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Pause the running agent.""" + bridge = _get_bridge(context) + if not bridge or not bridge.is_running(): + await update.message.reply_text("No agent is running.") + return + + bridge.pause_agent() + await update.message.reply_text("Agent paused. Use /resume to continue.") + + +@authorized +async def resume_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Resume a paused agent.""" + bridge = _get_bridge(context) + if not bridge: + await update.message.reply_text("No agent to resume. Use /deploy first.") + return + + if bridge.is_running(): + await update.message.reply_text("Agent is already running.") + return + + try: + bridge.resume_agent() + await update.message.reply_text("Agent resumed.") + except Exception as e: + await update.message.reply_text(f"Failed to resume: {e}") + + +@authorized +async def stop_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Stop the running agent.""" + bridge = _get_bridge(context) + if not bridge or not bridge.is_running(): + await update.message.reply_text("No agent is running.") + return + + summary = bridge.stop_agent() + text = shutdown_card( + tick_count=summary.get("tick_count", 0), + total_placed=summary.get("total_placed", 0), + total_filled=summary.get("total_filled", 0), + total_pnl=summary.get("total_pnl", 0.0), + elapsed_s=summary.get("elapsed_s", 0.0), + ) + await update.message.reply_text(text) + + +@authorized +async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show account balance.""" + from cli.keystore import list_keystores + + keystores = list_keystores() + if not keystores: + await update.message.reply_text("No wallet found. Use /start first.") + return + + address = keystores[0]["address"] + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + try: + from common.credentials import resolve_private_key + from parent.hl_proxy import HLProxy + from cli.hl_adapter import DirectHLProxy + + private_key = resolve_private_key(venue="hl") + raw_hl = HLProxy(private_key=private_key, testnet=(network != "mainnet")) + hl = DirectHLProxy(raw_hl) + account = hl.get_account_state() + + bal = 0.0 + if "crossMarginSummary" in account: + bal = float(account["crossMarginSummary"].get("accountValue", 0)) + elif "marginSummary" in account: + bal = float(account["marginSummary"].get("accountValue", 0)) + + await update.message.reply_text(balance_card(address, bal, network)) + except Exception as e: + log.error("Balance check failed: %s", e) + await update.message.reply_text(f"Could not fetch balance: {e}") + + +@authorized +async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show help.""" + await update.message.reply_text(help_card()) + + +async def control_button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle inline control button presses.""" + query = update.callback_query + await query.answer() + + action = query.data.replace("ctrl_", "") + + if action == "status": + bridge = _get_bridge(context) + if not bridge or not bridge.is_running(): + await query.edit_message_text("No agent is running.") + return + info = bridge.get_status() + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + text = status_card( + strategy=info["strategy"], + instrument=info["instrument"], + network=network, + tick_count=info["tick_count"], + pos_qty=info["pos_qty"], + avg_entry=info["avg_entry"], + upnl=info["upnl"], + rpnl=info["rpnl"], + elapsed_s=info["elapsed_s"], + risk_ok=info["risk_ok"], + ) + await query.edit_message_text(text, reply_markup=control_keyboard()) + + elif action == "pause": + bridge = _get_bridge(context) + if bridge and bridge.is_running(): + bridge.pause_agent() + await query.edit_message_text("Agent paused. Use /resume to continue.") + else: + await query.edit_message_text("No agent is running.") + + elif action == "resume": + bridge = _get_bridge(context) + if bridge: + bridge.resume_agent() + await query.edit_message_text("Agent resumed.", reply_markup=control_keyboard()) + else: + await query.edit_message_text("No agent to resume.") + + elif action == "stop": + bridge = _get_bridge(context) + if bridge and bridge.is_running(): + summary = bridge.stop_agent() + text = shutdown_card( + tick_count=summary.get("tick_count", 0), + total_placed=summary.get("total_placed", 0), + total_filled=summary.get("total_filled", 0), + total_pnl=summary.get("total_pnl", 0.0), + elapsed_s=summary.get("elapsed_s", 0.0), + ) + await query.edit_message_text(text) + else: + await query.edit_message_text("No agent is running.") + + elif action == "balance": + from cli.keystore import list_keystores + keystores = list_keystores() + if not keystores: + await query.edit_message_text("No wallet found.") + return + # Simplified — just show address + address = keystores[0]["address"] + await query.edit_message_text(f"Wallet: {address}\nUse /balance for full details.") + + +def register_control_handlers(app) -> None: + """Register all control command handlers.""" + app.add_handler(CommandHandler("status", status_cmd)) + app.add_handler(CommandHandler("pause", pause_cmd)) + app.add_handler(CommandHandler("resume", resume_cmd)) + app.add_handler(CommandHandler("stop", stop_cmd)) + app.add_handler(CommandHandler("balance", balance_cmd)) + app.add_handler(CommandHandler("help", help_cmd)) + app.add_handler(CallbackQueryHandler(control_button_callback, pattern=r"^ctrl_")) diff --git a/tg_bot/handlers/start.py b/tg_bot/handlers/start.py new file mode 100644 index 0000000..b4bbfd0 --- /dev/null +++ b/tg_bot/handlers/start.py @@ -0,0 +1,194 @@ +"""Wallet creation and onboarding flow.""" +from __future__ import annotations + +import logging +import os +import secrets + +from telegram import Update +from telegram.ext import ( + CallbackQueryHandler, + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) + +from tg_bot.auth import authorized +from tg_bot.formatters import ( + wallet_created_card, + wallet_keyboard, + welcome_card, +) + +log = logging.getLogger("telegram.start") + +# Conversation states +CHOOSE_ACTION, IMPORT_KEY = range(2) + + +@authorized +async def start_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Entry point: /start — check wallet, show welcome.""" + from cli.keystore import list_keystores + + keystores = list_keystores() + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + if keystores: + address = keystores[0]["address"] + # Try to get balance + balance = 0.0 + try: + balance = await _get_balance(address, network) + except Exception: + pass + await update.message.reply_text(welcome_card(True, address, balance)) + return ConversationHandler.END + + await update.message.reply_text( + welcome_card(False), + reply_markup=wallet_keyboard(), + ) + return CHOOSE_ACTION + + +async def wallet_create_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Create a new encrypted wallet.""" + query = update.callback_query + await query.answer() + + from cli.keystore import create_keystore, ENV_FILE + from eth_account import Account + + # Generate random key + password + account = Account.create() + private_key = account.key.hex() + if not private_key.startswith("0x"): + private_key = "0x" + private_key + password = secrets.token_urlsafe(24) + + # Save to keystore + ks_path = create_keystore(private_key, password) + + # Persist password for auto-unlock + ENV_FILE.parent.mkdir(parents=True, exist_ok=True) + lines = [] + if ENV_FILE.exists(): + lines = ENV_FILE.read_text().splitlines() + lines = [l for l in lines if not l.startswith("HL_KEYSTORE_PASSWORD=")] + lines.append(f"HL_KEYSTORE_PASSWORD={password}") + ENV_FILE.write_text("\n".join(lines) + "\n") + + address = account.address + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + log.info("Created wallet %s (keystore: %s)", address, ks_path) + await query.edit_message_text(wallet_created_card(address, network)) + return ConversationHandler.END + + +async def wallet_import_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Prompt user to send private key.""" + query = update.callback_query + await query.answer() + await query.edit_message_text( + "Send your private key (hex format with 0x prefix).\n" + "The message will be deleted immediately for security." + ) + return IMPORT_KEY + + +async def receive_private_key(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Receive and encrypt a private key. Delete the user's message immediately.""" + import secrets + from cli.keystore import create_keystore, ENV_FILE + + # Delete the message containing the private key immediately + try: + await update.message.delete() + except Exception: + log.warning("Could not delete message containing private key") + + private_key = update.message.text.strip() + if not private_key.startswith("0x"): + private_key = "0x" + private_key + + # Validate + try: + from eth_account import Account + account = Account.from_key(private_key) + except Exception: + await update.message.reply_text( + "Invalid private key. Must be a 64-character hex string (with or without 0x prefix).\n" + "Try again or use /start to create a new wallet." + ) + return ConversationHandler.END + + password = secrets.token_urlsafe(24) + ks_path = create_keystore(private_key, password) + + # Persist password + ENV_FILE.parent.mkdir(parents=True, exist_ok=True) + lines = [] + if ENV_FILE.exists(): + lines = ENV_FILE.read_text().splitlines() + lines = [l for l in lines if not l.startswith("HL_KEYSTORE_PASSWORD=")] + lines.append(f"HL_KEYSTORE_PASSWORD={password}") + ENV_FILE.write_text("\n".join(lines) + "\n") + + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + log.info("Imported wallet %s (keystore: %s)", account.address, ks_path) + await context.bot.send_message( + chat_id=update.effective_chat.id, + text=wallet_created_card(account.address, network), + ) + return ConversationHandler.END + + +async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.message.reply_text("Cancelled.") + return ConversationHandler.END + + +async def _get_balance(address: str, network: str) -> float: + """Get account balance from HL. Returns 0 on failure.""" + try: + from common.credentials import resolve_private_key + from parent.hl_proxy import HLProxy + from cli.hl_adapter import DirectHLProxy + + private_key = resolve_private_key(venue="hl") + testnet = network != "mainnet" + raw_hl = HLProxy(private_key=private_key, testnet=testnet) + hl = DirectHLProxy(raw_hl) + account = hl.get_account_state() + if "crossMarginSummary" in account: + return float(account["crossMarginSummary"].get("accountValue", 0)) + if "marginSummary" in account: + return float(account["marginSummary"].get("accountValue", 0)) + except Exception as e: + log.debug("Balance check failed: %s", e) + return 0.0 + + +def build_start_handler() -> ConversationHandler: + """Build the /start conversation handler.""" + return ConversationHandler( + entry_points=[CommandHandler("start", start_cmd)], + states={ + CHOOSE_ACTION: [ + CallbackQueryHandler(wallet_create_callback, pattern="^wallet_create$"), + CallbackQueryHandler(wallet_import_callback, pattern="^wallet_import$"), + ], + IMPORT_KEY: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_private_key), + ], + }, + fallbacks=[CommandHandler("cancel", cancel)], + ) diff --git a/tg_bot/handlers/strategy.py b/tg_bot/handlers/strategy.py new file mode 100644 index 0000000..1691dd9 --- /dev/null +++ b/tg_bot/handlers/strategy.py @@ -0,0 +1,267 @@ +"""Strategy selection and agent deployment flow.""" +from __future__ import annotations + +import logging + +from telegram import Update +from telegram.ext import ( + CallbackQueryHandler, + CommandHandler, + ContextTypes, + ConversationHandler, +) + +from tg_bot.auth import authorized +from tg_bot.formatters import ( + confirm_keyboard, + deploy_confirm_card, + instrument_keyboard, + mainnet_confirm_keyboard, + preset_keyboard, + strategy_info_card, + strategy_keyboard, +) + +log = logging.getLogger("telegram.strategy") + +# Conversation states +CHOOSE_STRATEGY, CHOOSE_INSTRUMENT, CHOOSE_PRESET, CONFIRM, MAINNET_CONFIRM = range(5) + +# Risk presets +PRESETS = { + "conservative": { + "max_position_qty": 2.0, + "max_notional_usd": 5000.0, + "max_order_size": 1.0, + "max_leverage": 2.0, + "tvl": 10000.0, + }, + "default": { + "max_position_qty": 10.0, + "max_notional_usd": 25000.0, + "max_order_size": 5.0, + "max_leverage": 3.0, + "tvl": 100000.0, + }, + "aggressive": { + "max_position_qty": 25.0, + "max_notional_usd": 100000.0, + "max_order_size": 10.0, + "max_leverage": 5.0, + "tvl": 250000.0, + }, +} + + +def _get_registry(): + from cli.strategy_registry import STRATEGY_REGISTRY + return STRATEGY_REGISTRY + + +@authorized +async def deploy_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Entry point: /deploy — select strategy.""" + # Check if agent is already running + bridge = context.bot_data.get("engine_bridge") + if bridge and bridge.is_running(): + await update.message.reply_text( + "An agent is already running. Use /stop first, then /deploy again." + ) + return ConversationHandler.END + + # Check wallet exists + from cli.keystore import list_keystores + if not list_keystores(): + await update.message.reply_text("No wallet found. Use /start first to create one.") + return ConversationHandler.END + + registry = _get_registry() + await update.message.reply_text( + "Choose a strategy:", + reply_markup=strategy_keyboard(registry, page=0), + ) + return CHOOSE_STRATEGY + + +async def strategy_page_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handle strategy pagination.""" + query = update.callback_query + await query.answer() + page = int(query.data.split("_")[-1]) + registry = _get_registry() + await query.edit_message_reply_markup( + reply_markup=strategy_keyboard(registry, page=page), + ) + return CHOOSE_STRATEGY + + +async def strategy_select_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handle strategy selection.""" + query = update.callback_query + await query.answer() + strategy_name = query.data.replace("strat_", "") + + registry = _get_registry() + if strategy_name not in registry: + await query.edit_message_text(f"Unknown strategy: {strategy_name}") + return ConversationHandler.END + + context.user_data["deploy_strategy"] = strategy_name + + # Show strategy info + instrument picker + info = registry[strategy_name] + text = strategy_info_card(strategy_name, info) + "\n\nChoose instrument:" + await query.edit_message_text(text, reply_markup=instrument_keyboard()) + return CHOOSE_INSTRUMENT + + +async def instrument_select_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handle instrument selection.""" + query = update.callback_query + await query.answer() + instrument = query.data.replace("inst_", "") + context.user_data["deploy_instrument"] = instrument + + await query.edit_message_text( + f"Strategy: {context.user_data['deploy_strategy']}\n" + f"Instrument: {instrument}\n\n" + "Choose risk preset:", + reply_markup=preset_keyboard(), + ) + return CHOOSE_PRESET + + +async def preset_select_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Handle preset selection, show confirmation.""" + query = update.callback_query + await query.answer() + preset_name = query.data.replace("preset_", "") + context.user_data["deploy_preset"] = preset_name + risk_params = PRESETS.get(preset_name, PRESETS["default"]) + context.user_data["deploy_risk_params"] = risk_params + + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + text = deploy_confirm_card( + strategy=context.user_data["deploy_strategy"], + instrument=context.user_data["deploy_instrument"], + preset=preset_name, + network=network, + risk_params=risk_params, + ) + await query.edit_message_text(text, reply_markup=confirm_keyboard(mainnet=network == "mainnet")) + return CONFIRM + + +async def confirm_deploy_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Confirm deployment — or gate to mainnet double-confirm.""" + query = update.callback_query + await query.answer() + + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + if network == "mainnet" and config and config.mainnet_confirmation: + await query.edit_message_text( + "WARNING: You are about to deploy on MAINNET with REAL funds.\n" + "Are you absolutely sure?", + reply_markup=mainnet_confirm_keyboard(), + ) + return MAINNET_CONFIRM + + return await _do_deploy(query, context) + + +async def mainnet_confirm_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Mainnet double confirmation.""" + query = update.callback_query + await query.answer() + + if query.data == "mainnet_confirm_yes": + return await _do_deploy(query, context) + else: + await query.edit_message_text("Deployment cancelled.") + return ConversationHandler.END + + +async def confirm_cancel_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Cancel deployment.""" + query = update.callback_query + await query.answer() + await query.edit_message_text("Deployment cancelled.") + return ConversationHandler.END + + +async def _do_deploy(query, context: ContextTypes.DEFAULT_TYPE) -> int: + """Actually start the trading engine.""" + strategy = context.user_data["deploy_strategy"] + instrument = context.user_data["deploy_instrument"] + preset = context.user_data["deploy_preset"] + risk_params = context.user_data["deploy_risk_params"] + + config = context.bot_data.get("config") + network = config.default_network if config else "testnet" + + bridge = context.bot_data.get("engine_bridge") + if not bridge: + await query.edit_message_text("Engine bridge not initialized. Contact admin.") + return ConversationHandler.END + + try: + await query.edit_message_text(f"Deploying {strategy} on {instrument}...") + + result = bridge.start_agent( + strategy_name=strategy, + instrument=instrument, + mainnet=(network == "mainnet"), + risk_overrides=risk_params, + ) + + from tg_bot.formatters import control_keyboard + await query.edit_message_text( + f"Agent deployed!\n\n" + f"Strategy: {strategy}\n" + f"Instrument: {instrument}\n" + f"Network: {network}\n" + f"Preset: {preset}\n\n" + f"Use the buttons below or /status to monitor.", + reply_markup=control_keyboard(), + ) + except Exception as e: + log.error("Failed to deploy agent: %s", e, exc_info=True) + await query.edit_message_text(f"Deployment failed: {e}") + + return ConversationHandler.END + + +async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.message.reply_text("Deployment cancelled.") + return ConversationHandler.END + + +def build_deploy_handler() -> ConversationHandler: + """Build the /deploy conversation handler.""" + return ConversationHandler( + entry_points=[CommandHandler("deploy", deploy_cmd)], + states={ + CHOOSE_STRATEGY: [ + CallbackQueryHandler(strategy_page_callback, pattern=r"^strat_page_\d+$"), + CallbackQueryHandler(strategy_select_callback, pattern=r"^strat_(?!page_)\w+$"), + ], + CHOOSE_INSTRUMENT: [ + CallbackQueryHandler(instrument_select_callback, pattern=r"^inst_"), + ], + CHOOSE_PRESET: [ + CallbackQueryHandler(preset_select_callback, pattern=r"^preset_"), + ], + CONFIRM: [ + CallbackQueryHandler(confirm_deploy_callback, pattern="^confirm_deploy$"), + CallbackQueryHandler(confirm_cancel_callback, pattern="^confirm_cancel$"), + ], + MAINNET_CONFIRM: [ + CallbackQueryHandler(mainnet_confirm_callback, pattern=r"^mainnet_confirm_"), + ], + }, + fallbacks=[CommandHandler("cancel", cancel)], + ) diff --git a/tg_bot/notifier.py b/tg_bot/notifier.py new file mode 100644 index 0000000..342f272 --- /dev/null +++ b/tg_bot/notifier.py @@ -0,0 +1,134 @@ +"""Event notification system — pushes engine events to Telegram with throttling.""" +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any, Dict, Optional + +from telegram import Bot + +from tg_bot.formatters import fill_card, shutdown_card, status_card + +log = logging.getLogger("telegram.notifier") + + +class Notifier: + """Reads events from engine queue and sends Telegram messages with throttling.""" + + def __init__( + self, + bot: Bot, + chat_id: int, + event_queue: asyncio.Queue, + pnl_interval_s: int = 60, + tick_summary_interval_s: int = 300, + ): + self.bot = bot + self.chat_id = chat_id + self.event_queue = event_queue + self.pnl_interval_s = pnl_interval_s + self.tick_summary_interval_s = tick_summary_interval_s + self._last_pnl_sent = 0.0 + self._last_tick_summary = 0.0 + self._task: Optional[asyncio.Task] = None + + def start(self) -> None: + """Start the notifier as a background task.""" + self._task = asyncio.create_task(self._run()) + log.info("Notifier started (pnl_interval=%ds, tick_summary=%ds)", + self.pnl_interval_s, self.tick_summary_interval_s) + + def stop(self) -> None: + """Stop the notifier.""" + if self._task: + self._task.cancel() + self._task = None + + async def _run(self) -> None: + """Main loop: read events from queue, format, send.""" + while True: + try: + event = await self.event_queue.get() + await self._handle_event(event) + except asyncio.CancelledError: + break + except Exception as e: + log.error("Notifier error: %s", e, exc_info=True) + + async def _handle_event(self, event: Dict[str, Any]) -> None: + event_type = event.get("type") + + if event_type == "fill": + await self._send_fill(event) + elif event_type == "tick": + await self._maybe_send_tick_summary(event) + elif event_type == "shutdown": + await self._send_shutdown(event) + elif event_type == "error": + await self._send_error(event) + elif event_type == "risk_alert": + await self._send_risk_alert(event) + + async def _send_fill(self, event: Dict[str, Any]) -> None: + """Always send fill notifications.""" + text = fill_card( + side=event["side"], + quantity=event["quantity"], + price=event["price"], + instrument=event["instrument"], + strategy=event["strategy"], + tick=event["tick"], + ) + await self._send(text) + + async def _maybe_send_tick_summary(self, event: Dict[str, Any]) -> None: + """Send PnL updates at throttled intervals.""" + now = time.time() + + # Always-send conditions: safe mode or reduce-only transitions + if event.get("safe_mode") or event.get("reduce_only"): + await self._send( + f"RISK ALERT: {'Safe mode' if event.get('safe_mode') else 'Reduce-only'} active\n" + f"Strategy: {event['strategy']} | Tick: {event['tick_count']}" + ) + return + + # Throttled PnL update + if now - self._last_tick_summary < self.tick_summary_interval_s: + return + + self._last_tick_summary = now + sign = lambda v: f"+{v:.2f}" if v >= 0 else f"{v:.2f}" + total = event.get("upnl", 0) + event.get("rpnl", 0) + text = ( + f"T{event['tick_count']} | {event['instrument']} mid={event['mid_price']:.4f}\n" + f"Pos: {sign(event['pos_qty'])} | PnL: ${sign(total)}" + ) + await self._send(text) + + async def _send_shutdown(self, event: Dict[str, Any]) -> None: + """Always send shutdown summary.""" + text = shutdown_card( + tick_count=event["tick_count"], + total_placed=event["total_placed"], + total_filled=event["total_filled"], + total_pnl=event["total_pnl"], + elapsed_s=event["elapsed_s"], + ) + await self._send(text) + + async def _send_error(self, event: Dict[str, Any]) -> None: + """Always send error notifications.""" + await self._send(f"ERROR: {event.get('message', 'Unknown error')}") + + async def _send_risk_alert(self, event: Dict[str, Any]) -> None: + """Always send risk alerts.""" + await self._send(f"RISK ALERT: {event.get('message', 'Risk event triggered')}") + + async def _send(self, text: str) -> None: + """Send a message to the configured chat.""" + try: + await self.bot.send_message(chat_id=self.chat_id, text=text) + except Exception as e: + log.error("Failed to send Telegram message: %s", e) From 934b1bc35f16d736b1ad3a38f9b36cf522f066ec Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Tue, 31 Mar 2026 13:44:56 +0100 Subject: [PATCH 2/5] feat: add `hl jobs` command group for perpetual agent job integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design PR — spec document + code skeleton (type stubs, interfaces, config schemas) for integrating all 8 perpetual on-chain agent jobs into the hl CLI. - New `hl jobs` command group (list/info/register/run/status/claim/deregister) - JobEngine ABC with KeeperEngine, CooperativeEngine, ManagedEngine - JobEngineFactory dispatches on job category (keeper/operator/cooperative/managed) - Strategy interfaces: KeeperStrategy, CooperativeStrategy, ManagedStrategy - EventSubscriber ABC with ChainEventSubscriber (WebSocket) and LogPollingSubscriber - CustodyGuard for pre-signing tx validation (defense-in-depth) - JOB_REGISTRY with all 8 jobs pre-registered - JobConfig dataclass with YAML loading - LaTeX specification at docs/agent_cli_jobs_spec.tex --- cli/jobs/__init__.py | 6 + cli/jobs/commands.py | 157 ++++ cli/jobs/config.py | 89 +++ cli/jobs/custody.py | 85 ++ cli/jobs/engines.py | 286 +++++++ cli/jobs/events.py | 182 +++++ cli/jobs/registry.py | 346 ++++++++ cli/jobs/status.py | 149 ++++ cli/jobs/strategy_interfaces.py | 244 ++++++ cli/main.py | 2 + docs/agent_cli_jobs_spec.tex | 1305 +++++++++++++++++++++++++++++++ 11 files changed, 2851 insertions(+) create mode 100644 cli/jobs/__init__.py create mode 100644 cli/jobs/commands.py create mode 100644 cli/jobs/config.py create mode 100644 cli/jobs/custody.py create mode 100644 cli/jobs/engines.py create mode 100644 cli/jobs/events.py create mode 100644 cli/jobs/registry.py create mode 100644 cli/jobs/status.py create mode 100644 cli/jobs/strategy_interfaces.py create mode 100644 docs/agent_cli_jobs_spec.tex diff --git a/cli/jobs/__init__.py b/cli/jobs/__init__.py new file mode 100644 index 0000000..11523ef --- /dev/null +++ b/cli/jobs/__init__.py @@ -0,0 +1,6 @@ +"""Agent job integration for the hl CLI. + +Provides the `hl jobs` command group for registering, running, monitoring, +and managing perpetual on-chain agent jobs as defined in the Perpetual Agent +Jobs Specification v1.0. +""" diff --git a/cli/jobs/commands.py b/cli/jobs/commands.py new file mode 100644 index 0000000..cb4717c --- /dev/null +++ b/cli/jobs/commands.py @@ -0,0 +1,157 @@ +"""hl jobs — CLI commands for Perpetual Agent Jobs.""" +from __future__ import annotations + +from typing import Optional + +import typer + +jobs_app = typer.Typer( + name="jobs", + help="Perpetual Agent Jobs — register, run, and manage on-chain agent jobs.", + no_args_is_help=True, +) + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +@jobs_app.command("list") +def list_jobs_cmd() -> None: + """Show all available job types.""" + from cli.jobs.registry import list_jobs + + all_jobs = list_jobs() + + header = f"{'ID':<20} {'Name':<25} {'Category':<14} {'Trigger':<18} {'TEE':<6} {'Min Stake':<12}" + typer.echo(header) + typer.echo("-" * len(header)) + + for job in all_jobs: + tee_str = "yes" if job.requires_tee else "no" + stake_str = f"{job.min_stake_eth:.0f} ETH" if job.min_stake_eth > 0 else "none" + typer.echo( + f"{job.job_id:<20} " + f"{job.name:<25} " + f"{job.category.value:<14} " + f"{job.trigger.value:<18} " + f"{tee_str:<6} " + f"{stake_str:<12}" + ) + + typer.echo(f"\n{len(all_jobs)} jobs registered.") + + +# --------------------------------------------------------------------------- +# info +# --------------------------------------------------------------------------- + + +@jobs_app.command("info") +def job_info( + job_id: str = typer.Argument(..., help="Job identifier (e.g. oracle_updater)"), +) -> None: + """Show detailed info for a job type.""" + from cli.jobs.registry import get_job + + try: + job = get_job(job_id) + except KeyError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + + typer.echo(f"Job: {job.name}") + typer.echo(f"ID: {job.job_id}") + typer.echo(f"Category: {job.category.value}") + typer.echo(f"Description: {job.description}") + typer.echo(f"Trigger: {job.trigger.value}") + if job.trigger_config: + for k, v in job.trigger_config.items(): + typer.echo(f" {k}: {v}") + typer.echo(f"Role: {job.required_role or 'none (permissionless)'}") + typer.echo(f"TEE: {'required' if job.requires_tee else 'not required'}") + typer.echo(f"Min Stake: {job.min_stake_eth:.0f} ETH") + typer.echo(f"Engine: {job.engine_type}") + typer.echo(f"Strategy: {job.strategy_interface}") + typer.echo(f"Context: {job.context_template}") + + typer.echo("\nCustody Policy:") + typer.echo(f" Destinations: {', '.join(job.custody.destinations) or 'none'}") + typer.echo(f" Selectors: {', '.join(job.custody.selectors) or 'none'}") + typer.echo(f" Value Cap: {job.custody.value_cap_eth} ETH") + typer.echo(f" Rate Limit/Block: {job.custody.rate_limit_per_block}") + + +# --------------------------------------------------------------------------- +# register +# --------------------------------------------------------------------------- + + +@jobs_app.command("register") +def register_job( + job_id: str = typer.Argument(..., help="Job identifier"), + stake: float = typer.Option(0.0, help="Stake amount in ETH"), + tee_attest: bool = typer.Option(False, "--tee", help="Include TEE attestation"), + config: Optional[str] = typer.Option(None, help="Path to job config YAML"), + mainnet: bool = typer.Option(False, help="Use mainnet"), +) -> None: + """Register as an agent for a job on-chain.""" + typer.echo("Not yet implemented — design PR only") + + +# --------------------------------------------------------------------------- +# run +# --------------------------------------------------------------------------- + + +@jobs_app.command("run") +def run_job( + job_id: str = typer.Argument(..., help="Job identifier"), + config: Optional[str] = typer.Option(None, help="Path to job config YAML"), + dry_run: bool = typer.Option(False, "--dry-run", help="Simulate without submitting txs"), + mainnet: bool = typer.Option(False, help="Use mainnet"), +) -> None: + """Start a job engine for the specified job.""" + typer.echo("Not yet implemented — design PR only") + + +# --------------------------------------------------------------------------- +# status +# --------------------------------------------------------------------------- + + +@jobs_app.command("status") +def job_status( + job_id: Optional[str] = typer.Option(None, help="Filter by job ID"), +) -> None: + """Show status of running job engines.""" + typer.echo("Not yet implemented — design PR only") + + +# --------------------------------------------------------------------------- +# claim +# --------------------------------------------------------------------------- + + +@jobs_app.command("claim") +def claim_rewards( + job_id: str = typer.Argument(..., help="Job identifier"), + mainnet: bool = typer.Option(False, help="Use mainnet"), +) -> None: + """Claim accumulated rewards for a job.""" + typer.echo("Not yet implemented — design PR only") + + +# --------------------------------------------------------------------------- +# deregister +# --------------------------------------------------------------------------- + + +@jobs_app.command("deregister") +def deregister_job( + job_id: str = typer.Argument(..., help="Job identifier"), + mainnet: bool = typer.Option(False, help="Use mainnet"), +) -> None: + """Deregister from a job and unstake.""" + typer.echo("Not yet implemented — design PR only") diff --git a/cli/jobs/config.py b/cli/jobs/config.py new file mode 100644 index 0000000..349a353 --- /dev/null +++ b/cli/jobs/config.py @@ -0,0 +1,89 @@ +"""Job configuration — loads from YAML, validates, and exposes typed fields.""" +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml +from pydantic import BaseModel + + +class RiskLimits(BaseModel): + """Risk limits for cooperative jobs. Mirrors the clearing-layer RiskLimits.""" + + max_position_notional_usd: float = 0.0 + max_drawdown_pct: float = 0.0 + max_leverage: float = 1.0 + daily_loss_limit_usd: float = 0.0 + max_open_orders: int = 0 + concentration_limit_pct: float = 100.0 + + +@dataclass +class JobConfig: + """Configuration for a single agent job instance. + + Can be constructed directly or loaded from a YAML file via ``from_yaml``. + """ + + job_id: str = "" + agent_id: str = field(default_factory=lambda: f"agent-{uuid.uuid4().hex[:8]}") + mainnet: bool = False + chain_rpc: str = "" + event_bus_ws: str = "" + relay_url: str = "" + strategy: str = "" + strategy_params: Dict[str, Any] = field(default_factory=dict) + stake_amount: float = 0.0 + tee_enabled: bool = False + pcr_whitelist: List[str] = field(default_factory=list) + custody: Dict[str, Any] = field(default_factory=dict) + risk: Dict[str, Any] = field(default_factory=dict) + data_dir: str = "data/jobs" + + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + + @classmethod + def from_yaml(cls, path: str) -> JobConfig: + """Load a ``JobConfig`` from a YAML file. + + Parameters + ---------- + path: + Filesystem path to the YAML configuration file. + + Returns + ------- + JobConfig + A fully-populated configuration instance. + + Raises + ------ + FileNotFoundError + If *path* does not exist. + yaml.YAMLError + If the file contains invalid YAML. + """ + raw = Path(path).read_text() + data = yaml.safe_load(raw) + if not isinstance(data, dict): + raise ValueError(f"Expected a YAML mapping at top level, got {type(data).__name__}") + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) + + # ------------------------------------------------------------------ + # Converters + # ------------------------------------------------------------------ + + def to_risk_limits(self) -> RiskLimits: + """Convert the ``risk`` dict into a typed :class:`RiskLimits` object. + + Returns + ------- + RiskLimits + Validated risk limits ready for the cooperative engine. + """ + return RiskLimits(**self.risk) diff --git a/cli/jobs/custody.py b/cli/jobs/custody.py new file mode 100644 index 0000000..af01b3d --- /dev/null +++ b/cli/jobs/custody.py @@ -0,0 +1,85 @@ +"""Custody policy enforcement — defense-in-depth pre-signing guard. + +The :class:`CustodyGuard` validates every transaction against the job's +:class:`CustodyPolicy` before it is signed and submitted, enforcing +destination, selector, value, and rate-limit constraints. +""" +from __future__ import annotations + +from typing import Optional + +from cli.jobs.registry import CustodyPolicy +from cli.jobs.strategy_interfaces import Transaction + + +class CustodyViolation(Exception): + """Raised when a transaction violates the job's custody policy. + + Attributes + ---------- + reason: + Human-readable description of which constraint was violated. + tx: + The offending transaction. + """ + + def __init__(self, reason: str, tx: Optional[Transaction] = None) -> None: + self.reason = reason + self.tx = tx + super().__init__(reason) + + +class CustodyGuard: + """Pre-signing custody policy enforcement. + + Acts as a defense-in-depth layer: even though the on-chain + ``JobRegistry`` contract enforces custody constraints, the guard + catches violations locally before signing, saving gas and preventing + accidental mis-use. + + Parameters + ---------- + policy: + The :class:`CustodyPolicy` for the active job. + """ + + def __init__(self, policy: CustodyPolicy) -> None: + self._policy = policy + self._tx_count_this_block: int = 0 + self._current_block: int = 0 + + def validate(self, tx: Transaction) -> bool: + """Check a transaction against the custody policy. + + Validates four constraints in order: + + 1. ``tx.to`` must be in ``policy.destinations``. + 2. The first 4 bytes of ``tx.data`` (function selector) must be + in ``policy.selectors``. + 3. ``tx.value_wei`` must not exceed ``policy.value_cap_eth * 1e18``. + 4. The per-block rate limit must not be exceeded. + + Parameters + ---------- + tx: + The transaction to validate. + + Returns + ------- + bool + ``True`` if the transaction passes all checks. + + Raises + ------ + CustodyViolation + If any constraint is violated. + """ + raise NotImplementedError("Implementation deferred — design PR only") + + def reset_rate_limit(self) -> None: + """Reset the per-block rate-limit counter. + + Call this method at the start of each new block to allow the + agent to submit transactions for the new block. + """ + raise NotImplementedError("Implementation deferred — design PR only") diff --git a/cli/jobs/engines.py b/cli/jobs/engines.py new file mode 100644 index 0000000..052b411 --- /dev/null +++ b/cli/jobs/engines.py @@ -0,0 +1,286 @@ +"""Job execution engines — one per job category. + +All method bodies raise ``NotImplementedError`` — this is a design PR skeleton. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Optional + +from pydantic import BaseModel, Field + +from cli.jobs.config import JobConfig +from cli.jobs.registry import JobCategory, JobDefinition + + +# --------------------------------------------------------------------------- +# Status model +# --------------------------------------------------------------------------- + + +class JobStatus(BaseModel): + """Snapshot of a running job engine's operational state. + + Attributes + ---------- + job_id: + The job type identifier. + agent_id: + This agent's unique identifier. + running: + Whether the engine event loop is active. + category: + Job category string (keeper, operator, cooperative, managed). + ticks_processed: + Number of ticks / events processed since start. + last_heartbeat_block: + Block number of the most recent heartbeat. + accumulated_reward_eth: + Total unclaimed rewards in ETH. + events_received: + Total events received from the subscriber. + txs_submitted: + Total transactions submitted to the chain. + txs_succeeded: + Total transactions that succeeded on-chain. + txs_failed: + Total transactions that reverted or failed. + uptime_seconds: + Seconds since the engine was started. + error: + Most recent error message, or ``None``. + """ + + job_id: str + agent_id: str + running: bool = False + category: str = "" + ticks_processed: int = 0 + last_heartbeat_block: int = 0 + accumulated_reward_eth: float = 0.0 + events_received: int = 0 + txs_submitted: int = 0 + txs_succeeded: int = 0 + txs_failed: int = 0 + uptime_seconds: float = 0.0 + error: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Abstract base engine +# --------------------------------------------------------------------------- + + +class JobEngine(ABC): + """Base class for all job execution engines. + + Subclasses implement the event loop, heartbeat, and shutdown logic + appropriate to their job category. + """ + + def __init__(self, job_def: JobDefinition) -> None: + """Initialise the engine with a job definition. + + Parameters + ---------- + job_def: + The immutable specification for the job this engine will run. + """ + self._job_def = job_def + + @abstractmethod + def start(self, config: JobConfig) -> None: + """Start the engine's event loop. + + Parameters + ---------- + config: + Runtime configuration (RPC endpoints, strategy, TEE settings, etc.). + """ + ... + + @abstractmethod + def stop(self) -> None: + """Gracefully stop the engine and release resources.""" + ... + + @abstractmethod + def heartbeat(self) -> None: + """Send a heartbeat to the on-chain JobRegistry contract.""" + ... + + @abstractmethod + def status(self) -> JobStatus: + """Return a snapshot of the engine's current operational state.""" + ... + + +# --------------------------------------------------------------------------- +# Keeper / Operator engine +# --------------------------------------------------------------------------- + + +class KeeperEngine(JobEngine): + """Engine for KEEPER and OPERATOR jobs. + + Event-driven execution: subscribes to chain events, evaluates a + ``KeeperStrategy``, and submits transactions through a ``CustodyGuard``. + """ + + def __init__(self, job_def: JobDefinition) -> None: + super().__init__(job_def) + self._subscriber = None # EventSubscriber — set in start() + self._strategy = None # KeeperStrategy — loaded from config + self._custody_guard = None # CustodyGuard — built from job_def.custody + self._status = JobStatus(job_id=job_def.job_id, agent_id="") + + def start(self, config: JobConfig) -> None: + """Connect event subscriber, load strategy, enter event loop. + + Parameters + ---------- + config: + Runtime configuration for this job instance. + """ + raise NotImplementedError("Implementation deferred — design PR only") + + def stop(self) -> None: + """Unsubscribe from events and flush any pending state.""" + raise NotImplementedError("Implementation deferred — design PR only") + + def heartbeat(self) -> None: + """Call JobRegistry.heartbeat() on-chain.""" + raise NotImplementedError("Implementation deferred — design PR only") + + def status(self) -> JobStatus: + """Return current status with event and transaction counts.""" + raise NotImplementedError("Implementation deferred — design PR only") + + +# --------------------------------------------------------------------------- +# Cooperative engine +# --------------------------------------------------------------------------- + + +class CooperativeEngine(JobEngine): + """Engine for COOPERATIVE jobs. + + Wraps the tee-work-llm ``AgentClient`` to participate in TEE-cleared + cooperative rounds. The AgentClient is imported lazily since the + ``tee-work-llm`` package may not be installed. + """ + + def __init__(self, job_def: JobDefinition) -> None: + super().__init__(job_def) + self._client = None # AgentClient — created in start() + self._strategy = None # CooperativeStrategy — loaded from config + self._status = JobStatus(job_id=job_def.job_id, agent_id="") + + def start(self, config: JobConfig) -> None: + """Import AgentClient, create client, optionally attest, enter round loop. + + Parameters + ---------- + config: + Runtime configuration including relay URL and TEE settings. + """ + # Lazy import — tee-work-llm may not be installed + try: + from agent.client import AgentClient # noqa: F401 + except ImportError: + raise ImportError( + "tee-work-llm is required for cooperative jobs. " + "Install with: pip install tee-work-llm" + ) + raise NotImplementedError("Implementation deferred — design PR only") + + def stop(self) -> None: + """Gracefully shut down the AgentClient.""" + raise NotImplementedError("Implementation deferred — design PR only") + + def heartbeat(self) -> None: + """Send heartbeat between clearing rounds.""" + raise NotImplementedError("Implementation deferred — design PR only") + + def status(self) -> JobStatus: + """Return status with round count, participation rate, and PnL.""" + raise NotImplementedError("Implementation deferred — design PR only") + + +# --------------------------------------------------------------------------- +# Managed engine +# --------------------------------------------------------------------------- + + +class ManagedEngine(JobEngine): + """Engine for MANAGED jobs. + + Timer + event hybrid: runs a periodic evaluation cycle and also + reacts to specific on-chain events (e.g. ``WithdrawRequested``). + """ + + def __init__(self, job_def: JobDefinition) -> None: + super().__init__(job_def) + self._timer = None # asyncio timer handle + self._subscriber = None # EventSubscriber — for reactive events + self._strategy = None # ManagedStrategy — loaded from config + self._status = JobStatus(job_id=job_def.job_id, agent_id="") + + def start(self, config: JobConfig) -> None: + """Set up periodic timer and event subscriber, enter evaluation loop. + + Parameters + ---------- + config: + Runtime configuration for this managed job instance. + """ + raise NotImplementedError("Implementation deferred — design PR only") + + def stop(self) -> None: + """Cancel timer and unsubscribe from events.""" + raise NotImplementedError("Implementation deferred — design PR only") + + def heartbeat(self) -> None: + """Send heartbeat on each evaluation cycle.""" + raise NotImplementedError("Implementation deferred — design PR only") + + def status(self) -> JobStatus: + """Return status with actions taken and vault state.""" + raise NotImplementedError("Implementation deferred — design PR only") + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +class JobEngineFactory: + """Routes job definitions to the correct engine implementation.""" + + @staticmethod + def create(job_def: JobDefinition) -> JobEngine: + """Create the appropriate engine for a given job definition. + + Parameters + ---------- + job_def: + The job definition specifying the category and configuration. + + Returns + ------- + JobEngine + A concrete engine instance ready to be started. + + Raises + ------ + ValueError + If the job category is not recognised. + """ + if job_def.category in (JobCategory.KEEPER, JobCategory.OPERATOR): + return KeeperEngine(job_def) + elif job_def.category == JobCategory.COOPERATIVE: + return CooperativeEngine(job_def) + elif job_def.category == JobCategory.MANAGED: + return ManagedEngine(job_def) + else: + raise ValueError(f"Unknown job category: {job_def.category}") diff --git a/cli/jobs/events.py b/cli/jobs/events.py new file mode 100644 index 0000000..47ebfdd --- /dev/null +++ b/cli/jobs/events.py @@ -0,0 +1,182 @@ +"""Event subscription layer for Perpetual Agent Jobs. + +Provides abstract and concrete subscribers that deliver on-chain events +to job engines. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Awaitable, Callable, List + +from cli.jobs.config import JobConfig +from cli.jobs.strategy_interfaces import ChainEvent + + +# --------------------------------------------------------------------------- +# Abstract subscriber +# --------------------------------------------------------------------------- + + +class EventSubscriber(ABC): + """Subscribes to on-chain events and dispatches them to callbacks. + + Implementations connect to a chain data source (WebSocket, RPC polling) + and invoke a callback for each matching event. + """ + + @abstractmethod + async def subscribe( + self, + event_types: List[str], + callback: Callable[[ChainEvent], Awaitable[None]], + ) -> None: + """Start receiving events of the specified types. + + Parameters + ---------- + event_types: + Event type discriminators to listen for (e.g. + ``["NewBlock", "OracleUpdate"]``). + callback: + Async callable invoked with each matching :class:`ChainEvent`. + """ + ... + + @abstractmethod + async def unsubscribe(self) -> None: + """Stop receiving events and close the underlying connection.""" + ... + + @abstractmethod + async def is_connected(self) -> bool: + """Check whether the subscriber is currently connected. + + Returns + ------- + bool + ``True`` if the connection is alive and subscriptions are active. + """ + ... + + +# --------------------------------------------------------------------------- +# Canonical event bus (WebSocket) +# --------------------------------------------------------------------------- + + +class ChainEventSubscriber(EventSubscriber): + """WebSocket connection to the canonical chain event bus. + + The event bus provides a persistent WebSocket stream of canonical + chain events (blocks, oracle updates, contract events) with guaranteed + ordering. + + Parameters + ---------- + ws_url: + WebSocket URL of the chain event bus. + """ + + def __init__(self, ws_url: str) -> None: + self._ws_url = ws_url + self._connection = None # websocket connection handle + self._subscribed = False + + async def subscribe( + self, + event_types: List[str], + callback: Callable[[ChainEvent], Awaitable[None]], + ) -> None: + """Subscribe to events via the WebSocket event bus. + + Parameters + ---------- + event_types: + Event types to subscribe to. + callback: + Async handler for each received event. + """ + raise NotImplementedError("Implementation deferred — design PR only") + + async def unsubscribe(self) -> None: + """Close the WebSocket connection and clear subscriptions.""" + raise NotImplementedError("Implementation deferred — design PR only") + + async def is_connected(self) -> bool: + """Return whether the WebSocket connection is alive.""" + raise NotImplementedError("Implementation deferred — design PR only") + + +# --------------------------------------------------------------------------- +# Log polling fallback (V1 contracts) +# --------------------------------------------------------------------------- + + +class LogPollingSubscriber(EventSubscriber): + """Fallback subscriber that polls ``eth_getLogs`` for contract events. + + Used when the canonical event bus is unavailable (e.g. V1 contract + deployments on standard EVM chains). + + Parameters + ---------- + rpc_url: + JSON-RPC URL to poll for logs. + poll_interval_s: + Seconds between ``eth_getLogs`` calls. + """ + + def __init__(self, rpc_url: str, poll_interval_s: float = 1.0) -> None: + self._rpc_url = rpc_url + self._poll_interval_s = poll_interval_s + self._polling = False + + async def subscribe( + self, + event_types: List[str], + callback: Callable[[ChainEvent], Awaitable[None]], + ) -> None: + """Start polling for log events matching the requested types. + + Parameters + ---------- + event_types: + Event types to filter for. + callback: + Async handler for each matched event. + """ + raise NotImplementedError("Implementation deferred — design PR only") + + async def unsubscribe(self) -> None: + """Stop the polling loop.""" + raise NotImplementedError("Implementation deferred — design PR only") + + async def is_connected(self) -> bool: + """Return whether the polling loop is active.""" + raise NotImplementedError("Implementation deferred — design PR only") + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def create_subscriber(config: JobConfig) -> EventSubscriber: + """Create the appropriate event subscriber for a job configuration. + + Uses :class:`ChainEventSubscriber` if ``config.event_bus_ws`` is set, + otherwise falls back to :class:`LogPollingSubscriber`. + + Parameters + ---------- + config: + Job configuration containing connection endpoints. + + Returns + ------- + EventSubscriber + A subscriber instance ready to be connected. + """ + if config.event_bus_ws: + return ChainEventSubscriber(config.event_bus_ws) + return LogPollingSubscriber(config.chain_rpc) diff --git a/cli/jobs/registry.py b/cli/jobs/registry.py new file mode 100644 index 0000000..e8e6d2d --- /dev/null +++ b/cli/jobs/registry.py @@ -0,0 +1,346 @@ +"""Job registry — canonical definitions for all Perpetual Agent Jobs.""" +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class JobCategory(str, Enum): + """High-level job classification.""" + + KEEPER = "keeper" + OPERATOR = "operator" + COOPERATIVE = "cooperative" + MANAGED = "managed" + + +class TriggerType(str, Enum): + """What event starts a job tick.""" + + NEW_BLOCK = "new_block" + ORACLE_UPDATE = "oracle_update" + EVENT = "event" + CLEARING_ROUND = "clearing_round" + TIMER = "timer" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +class CustodyPolicy(BaseModel): + """On-chain custody constraints enforced by JobRegistry and CustodyGuard. + + Attributes + ---------- + destinations: + Allowed contract addresses the agent may call. + selectors: + Allowed 4-byte function selectors (hex, e.g. ``"0x12345678"``). + value_cap_eth: + Maximum ETH value per transaction. + rate_limit_per_block: + Maximum number of transactions the agent may submit per block. + """ + + destinations: List[str] = Field(default_factory=list) + selectors: List[str] = Field(default_factory=list) + value_cap_eth: float = 0.0 + rate_limit_per_block: int = 1 + + +class JobDefinition(BaseModel): + """Immutable specification for a job type in the registry. + + Attributes + ---------- + job_id: + Unique slug (e.g. ``"oracle_updater"``). + name: + Human-readable display name. + description: + One-line summary of the job's purpose. + category: + Job category (keeper, operator, cooperative, managed). + trigger: + The event type that initiates a job tick. + trigger_config: + Extra parameters for the trigger (e.g. event name, timer interval). + required_role: + On-chain role the agent must hold, or ``None`` for permissionless. + requires_tee: + Whether TEE attestation is mandatory. + min_stake_eth: + Minimum stake in ETH to register for this job. + custody: + Custody policy constraining the agent's transaction scope. + strategy_interface: + Dotted path to the abstract strategy class the agent must implement. + context_template: + Name of the context model passed to the strategy each tick. + engine_type: + Which engine class handles execution (``"keeper"``, ``"cooperative"``, + ``"managed"``). + """ + + job_id: str + name: str + description: str = "" + category: JobCategory + trigger: TriggerType + trigger_config: Dict[str, Any] = Field(default_factory=dict) + required_role: Optional[str] = None + requires_tee: bool = False + min_stake_eth: float = 0.0 + custody: CustodyPolicy = Field(default_factory=CustodyPolicy) + strategy_interface: str = "" + context_template: str = "" + engine_type: str = "" + + +# --------------------------------------------------------------------------- +# Pre-registered jobs +# --------------------------------------------------------------------------- + +JOB_REGISTRY: Dict[str, JobDefinition] = { + # 1. Oracle Updater — KEEPER + "oracle_updater": JobDefinition( + job_id="oracle_updater", + name="Oracle Updater", + description="Push fresh oracle prices on each new block.", + category=JobCategory.KEEPER, + trigger=TriggerType.NEW_BLOCK, + trigger_config={}, + required_role=None, + requires_tee=False, + min_stake_eth=0.0, + custody=CustodyPolicy( + destinations=["TODO: deploy address — OracleManager", "TODO: deploy address — PythFeeds"], + selectors=["0x00000000"], # TODO: real selector after ABI finalised + value_cap_eth=0.0, + rate_limit_per_block=2, + ), + strategy_interface="cli.jobs.strategy_interfaces.KeeperStrategy", + context_template="KeeperContext", + engine_type="keeper", + ), + # 2. Funding Keeper — OPERATOR + "funding_keeper": JobDefinition( + job_id="funding_keeper", + name="Funding Keeper", + description="Settle funding rates on perpetual markets each block.", + category=JobCategory.OPERATOR, + trigger=TriggerType.NEW_BLOCK, + trigger_config={}, + required_role="AUTHORIZED", + requires_tee=False, + min_stake_eth=10.0, + custody=CustodyPolicy( + destinations=["TODO: deploy address — MarketRegistry"], + selectors=["0x00000000"], # TODO: real selector after ABI finalised + value_cap_eth=0.0, + rate_limit_per_block=1, + ), + strategy_interface="cli.jobs.strategy_interfaces.KeeperStrategy", + context_template="KeeperContext", + engine_type="keeper", + ), + # 3. Liquidation Flagger — KEEPER + "liq_flagger": JobDefinition( + job_id="liq_flagger", + name="Liquidation Flagger", + description="Flag under-collateralised positions for liquidation.", + category=JobCategory.KEEPER, + trigger=TriggerType.ORACLE_UPDATE, + trigger_config={}, + required_role=None, + requires_tee=False, + min_stake_eth=0.0, + custody=CustodyPolicy( + destinations=["TODO: deploy address — LiquidationModule"], + selectors=["0x00000000", "0x00000001"], # flagPosition, flagAccount + value_cap_eth=0.0, + rate_limit_per_block=5, + ), + strategy_interface="cli.jobs.strategy_interfaces.KeeperStrategy", + context_template="KeeperContext", + engine_type="keeper", + ), + # 4. Liquidation Executor — OPERATOR + "liq_executor": JobDefinition( + job_id="liq_executor", + name="Liquidation Executor", + description="Execute liquidations on flagged positions.", + category=JobCategory.OPERATOR, + trigger=TriggerType.EVENT, + trigger_config={"event_name": "PositionFlagged"}, + required_role="OPERATOR_ROLE", + requires_tee=False, + min_stake_eth=50.0, + custody=CustodyPolicy( + destinations=["TODO: deploy address — LiquidationModule"], + selectors=[ + "0x00000000", # liquidatePosition + "0x00000001", # liquidateAccount + "0x00000002", # liquidatePositions + ], + value_cap_eth=0.0, + rate_limit_per_block=10, + ), + strategy_interface="cli.jobs.strategy_interfaces.KeeperStrategy", + context_template="KeeperContext", + engine_type="keeper", + ), + # 5. TP/SL Agent — KEEPER + "tpsl_agent": JobDefinition( + job_id="tpsl_agent", + name="TP/SL Agent", + description="Execute take-profit / stop-loss orders on behalf of delegators.", + category=JobCategory.KEEPER, + trigger=TriggerType.ORACLE_UPDATE, + trigger_config={}, + required_role=None, # delegated via passport + requires_tee=False, + min_stake_eth=1.0, + custody=CustodyPolicy( + destinations=["TODO: deploy address — Orderbook"], + selectors=[ + "0x00000000", # placeOrder REDUCE_ONLY + "0x00000001", # closeOrder + ], + value_cap_eth=0.0, + rate_limit_per_block=3, + ), + strategy_interface="cli.jobs.strategy_interfaces.KeeperStrategy", + context_template="KeeperContext", + engine_type="keeper", + ), + # 6. Market Maker — COOPERATIVE + "market_maker": JobDefinition( + job_id="market_maker", + name="Market Maker", + description="Provide two-sided liquidity via TEE-cleared cooperative rounds.", + category=JobCategory.COOPERATIVE, + trigger=TriggerType.CLEARING_ROUND, + trigger_config={}, + required_role=None, # TEE + Stake gated + requires_tee=True, + min_stake_eth=100.0, + custody=CustodyPolicy( + destinations=["TODO: deploy address — ClearingHouse (via enclave)"], + selectors=["0x00000000"], # enclave-mediated + value_cap_eth=0.0, + rate_limit_per_block=1, + ), + strategy_interface="cli.jobs.strategy_interfaces.CooperativeStrategy", + context_template="StrategyContext", + engine_type="cooperative", + ), + # 7. ABM Agent — COOPERATIVE + "abm_agent": JobDefinition( + job_id="abm_agent", + name="ABM Agent", + description="Automated bin management for concentrated liquidity via TEE.", + category=JobCategory.COOPERATIVE, + trigger=TriggerType.ORACLE_UPDATE, + trigger_config={"deviation_threshold_bps": 50}, + required_role=None, # TEE + Stake gated + requires_tee=True, + min_stake_eth=50.0, + custody=CustodyPolicy( + destinations=[ + "TODO: deploy address — BinManager", + "TODO: deploy address — AMM", + ], + selectors=["0x00000000"], # enclave-mediated + value_cap_eth=0.0, + rate_limit_per_block=1, + ), + strategy_interface="cli.jobs.strategy_interfaces.CooperativeStrategy", + context_template="StrategyContext", + engine_type="cooperative", + ), + # 8. GLV Manager — MANAGED + "glv_manager": JobDefinition( + job_id="glv_manager", + name="GLV Capital Manager", + description="Manage vault capital allocation, deposits, withdrawals, and harvesting.", + category=JobCategory.MANAGED, + trigger=TriggerType.TIMER, + trigger_config={"interval_s": 60, "also_on_event": "WithdrawRequested"}, + required_role="MANAGER_ROLE", + requires_tee=False, + min_stake_eth=100.0, + custody=CustodyPolicy( + destinations=["TODO: deploy address — Glv"], + selectors=["0x00000000"], # TODO: real selectors after ABI finalised + value_cap_eth=10.0, + rate_limit_per_block=2, + ), + strategy_interface="cli.jobs.strategy_interfaces.ManagedStrategy", + context_template="ManagedContext", + engine_type="managed", + ), +} + + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + + +def get_job(job_id: str) -> JobDefinition: + """Look up a job definition by its identifier. + + Parameters + ---------- + job_id: + The unique slug of the job (e.g. ``"oracle_updater"``). + + Returns + ------- + JobDefinition + + Raises + ------ + KeyError + If *job_id* is not found in the registry. + """ + if job_id not in JOB_REGISTRY: + raise KeyError(f"Unknown job: {job_id!r}. Available: {', '.join(JOB_REGISTRY)}") + return JOB_REGISTRY[job_id] + + +def list_jobs() -> List[JobDefinition]: + """Return all registered job definitions. + + Returns + ------- + list[JobDefinition] + Every job in the registry, in insertion order. + """ + return list(JOB_REGISTRY.values()) + + +def list_jobs_by_category(category: JobCategory) -> List[JobDefinition]: + """Return job definitions filtered by category. + + Parameters + ---------- + category: + The :class:`JobCategory` to filter on. + + Returns + ------- + list[JobDefinition] + Jobs matching the requested category. + """ + return [j for j in JOB_REGISTRY.values() if j.category == category] diff --git a/cli/jobs/status.py b/cli/jobs/status.py new file mode 100644 index 0000000..89c8935 --- /dev/null +++ b/cli/jobs/status.py @@ -0,0 +1,149 @@ +"""Job status tracking — heartbeat state, reward accumulation, and persistence.""" +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +from cli.jobs.engines import JobStatus + + +@dataclass +class HeartbeatRecord: + """A single heartbeat record.""" + + block_number: int + timestamp_ms: int + success: bool + tx_hash: Optional[str] = None + + +@dataclass +class RewardRecord: + """A single reward claim record.""" + + amount_eth: float + block_number: int + timestamp_ms: int + tx_hash: str = "" + + +@dataclass +class JobStatusTracker: + """Tracks heartbeat and reward history for a running job. + + Persists state to a JSON file in the job's data directory so that + status can be read by ``hl jobs status`` even from a separate process. + """ + + job_id: str + agent_id: str + data_dir: str = "data/jobs" + heartbeats: List[HeartbeatRecord] = field(default_factory=list) + rewards: List[RewardRecord] = field(default_factory=list) + started_at: float = field(default_factory=time.time) + + # --- persistence --- + + def _state_path(self) -> Path: + return Path(self.data_dir) / self.job_id / "status.json" + + def save(self) -> None: + """Persist current status to disk.""" + path = self._state_path() + path.parent.mkdir(parents=True, exist_ok=True) + state = { + "job_id": self.job_id, + "agent_id": self.agent_id, + "started_at": self.started_at, + "heartbeat_count": len(self.heartbeats), + "last_heartbeat": ( + { + "block": self.heartbeats[-1].block_number, + "ts": self.heartbeats[-1].timestamp_ms, + "ok": self.heartbeats[-1].success, + } + if self.heartbeats + else None + ), + "total_rewards_eth": sum(r.amount_eth for r in self.rewards), + "reward_count": len(self.rewards), + } + path.write_text(json.dumps(state, indent=2)) + + @classmethod + def load(cls, job_id: str, data_dir: str = "data/jobs") -> Optional["JobStatusTracker"]: + """Load status from disk. Returns None if no status file exists.""" + path = Path(data_dir) / job_id / "status.json" + if not path.exists(): + return None + data = json.loads(path.read_text()) + tracker = cls( + job_id=data["job_id"], + agent_id=data["agent_id"], + data_dir=data_dir, + started_at=data.get("started_at", 0.0), + ) + return tracker + + # --- recording --- + + def record_heartbeat(self, block_number: int, success: bool, tx_hash: Optional[str] = None) -> None: + """Record a heartbeat and auto-save.""" + self.heartbeats.append( + HeartbeatRecord( + block_number=block_number, + timestamp_ms=int(time.time() * 1000), + success=success, + tx_hash=tx_hash, + ) + ) + self.save() + + def record_reward(self, amount_eth: float, block_number: int, tx_hash: str = "") -> None: + """Record a reward claim and auto-save.""" + self.rewards.append( + RewardRecord( + amount_eth=amount_eth, + block_number=block_number, + timestamp_ms=int(time.time() * 1000), + tx_hash=tx_hash, + ) + ) + self.save() + + # --- queries --- + + def total_rewards(self) -> float: + """Total ETH rewards claimed.""" + return sum(r.amount_eth for r in self.rewards) + + def uptime_seconds(self) -> float: + """Seconds since job started.""" + return time.time() - self.started_at + + def last_heartbeat_age_s(self) -> Optional[float]: + """Seconds since last heartbeat, or None if no heartbeats.""" + if not self.heartbeats: + return None + return (time.time() * 1000 - self.heartbeats[-1].timestamp_ms) / 1000 + + +def read_all_job_statuses(data_dir: str = "data/jobs") -> Dict[str, dict]: + """Read status files for all jobs in the data directory.""" + results: Dict[str, dict] = {} + base = Path(data_dir) + if not base.exists(): + return results + for job_dir in base.iterdir(): + if not job_dir.is_dir(): + continue + status_file = job_dir / "status.json" + if status_file.exists(): + try: + results[job_dir.name] = json.loads(status_file.read_text()) + except (json.JSONDecodeError, OSError): + continue + return results diff --git a/cli/jobs/strategy_interfaces.py b/cli/jobs/strategy_interfaces.py new file mode 100644 index 0000000..4f17d39 --- /dev/null +++ b/cli/jobs/strategy_interfaces.py @@ -0,0 +1,244 @@ +"""Strategy interfaces for Perpetual Agent Jobs. + +Defines the abstract contracts that job strategies must implement: + +- :class:`KeeperStrategy` — event-driven, stateless (keepers and operators). +- :class:`CooperativeStrategy` — round-based, extends :class:`BaseStrategy`. +- :class:`ManagedStrategy` — capital-management for vaults. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +from sdk.strategy_sdk.base import BaseStrategy + + +# --------------------------------------------------------------------------- +# Event / context models +# --------------------------------------------------------------------------- + + +class ChainEvent(BaseModel): + """An on-chain event received from the event bus. + + Attributes + ---------- + event_type: + Discriminator string, e.g. ``"NewBlock"``, ``"OracleUpdate"``, + ``"PositionFlagged"``. + block_number: + Block height at which the event was emitted. + tx_hash: + Originating transaction hash, if applicable. + data: + Arbitrary decoded event data. + timestamp_ms: + Unix timestamp in milliseconds. + """ + + event_type: str + block_number: int + tx_hash: Optional[str] = None + data: Dict[str, Any] = Field(default_factory=dict) + timestamp_ms: int = 0 + + +class KeeperContext(BaseModel): + """Context provided to keeper / operator strategies per event. + + Attributes + ---------- + event: + The triggering chain event. + chain_state: + Job-specific on-chain state snapshot (e.g. oracle prices, positions). + gas_price_gwei: + Current gas price for profitability checks. + agent_balance_eth: + Agent wallet balance in ETH. + """ + + event: ChainEvent + chain_state: Dict[str, Any] = Field(default_factory=dict) + gas_price_gwei: float = 0.0 + agent_balance_eth: float = 0.0 + + +class Transaction(BaseModel): + """A transaction to submit to the chain. + + Attributes + ---------- + to: + Target contract address (checksummed hex). + data: + ABI-encoded calldata (hex string, ``"0x..."``). + value_wei: + ETH value to send, in wei. + gas_limit: + Gas limit. ``0`` means the engine should estimate. + """ + + to: str + data: str + value_wei: int = 0 + gas_limit: int = 0 + + +# --------------------------------------------------------------------------- +# Keeper / Operator strategy +# --------------------------------------------------------------------------- + + +class KeeperStrategy(ABC): + """Strategy interface for Keeper and Operator jobs. + + Event-driven and stateless: receives a chain event with context, + decides whether to submit a transaction. + """ + + @abstractmethod + def should_execute( + self, event: ChainEvent, context: KeeperContext + ) -> Optional[Transaction]: + """Evaluate a chain event and optionally produce a transaction. + + Parameters + ---------- + event: + The chain event that triggered this evaluation. + context: + Contextual data including on-chain state and gas info. + + Returns + ------- + Transaction or None + A transaction to submit, or ``None`` to skip. + """ + ... + + def on_execution_result( + self, tx_hash: str, success: bool, gas_used: int + ) -> None: + """Optional callback invoked after a transaction is executed. + + Override to implement logging, metrics, or adaptive behaviour. + + Parameters + ---------- + tx_hash: + Hash of the submitted transaction. + success: + Whether the transaction succeeded on-chain. + gas_used: + Actual gas consumed. + """ + pass + + +# --------------------------------------------------------------------------- +# Managed infrastructure strategy +# --------------------------------------------------------------------------- + + +class CapitalAction(BaseModel): + """A capital-management action emitted by a managed strategy. + + Attributes + ---------- + action_type: + One of ``"deposit"``, ``"withdraw"``, ``"harvest"``. + target_contract: + Address of the contract to interact with. + calldata: + ABI-encoded calldata (hex string). + value_wei: + ETH value to send, in wei. + priority: + Execution priority (higher = more urgent). + """ + + action_type: str + target_contract: str + calldata: str + value_wei: int = 0 + priority: int = 0 + + +class ManagedContext(BaseModel): + """Context provided to managed infrastructure strategies. + + Attributes + ---------- + vault_balance_usd: + Current USD balance held in the vault contract. + pending_withdrawals: + List of pending withdrawal requests with amounts and deadlines. + total_assets_usd: + Total assets under management in USD. + clearing_account_balance_usd: + Balance in the clearing account used for active trading. + last_harvest_timestamp: + Unix timestamp of the last harvest operation. + block_number: + Current block height. + """ + + vault_balance_usd: float = 0.0 + pending_withdrawals: List[Dict[str, Any]] = Field(default_factory=list) + total_assets_usd: float = 0.0 + clearing_account_balance_usd: float = 0.0 + last_harvest_timestamp: int = 0 + block_number: int = 0 + + +class ManagedStrategy(ABC): + """Strategy interface for Managed Infrastructure jobs (e.g. GLV Capital Manager). + + Periodically evaluates vault state and returns a list of capital actions. + """ + + @abstractmethod + def evaluate(self, context: ManagedContext) -> List[CapitalAction]: + """Evaluate current vault state and return capital management actions. + + Parameters + ---------- + context: + Current vault and clearing account state. + + Returns + ------- + list[CapitalAction] + Zero or more actions to execute, ordered by priority. + """ + ... + + +# --------------------------------------------------------------------------- +# Cooperative strategy (extends BaseStrategy) +# --------------------------------------------------------------------------- + + +class CooperativeStrategy(BaseStrategy): + """Strategy for House Cooperative jobs. + + Extends :class:`BaseStrategy` (which provides ``on_tick``) with + post-clearing round feedback for adaptive learning. + """ + + def on_round_result(self, result: Any) -> None: + """Receive post-clearing feedback after a round completes. + + Override this method to implement adaptive learning based on + clearing outcomes, fills, and PnL attribution. + + Parameters + ---------- + result: + Clearing result data (format TBD by clearing layer). + """ + pass diff --git a/cli/main.py b/cli/main.py index e67b7dc..78d7220 100644 --- a/cli/main.py +++ b/cli/main.py @@ -36,6 +36,7 @@ from cli.commands.journal import journal_app from cli.commands.keys import keys_app from cli.commands.telegram_cmd import telegram_app +from cli.jobs.commands import jobs_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) @@ -55,6 +56,7 @@ 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(telegram_app, name="telegram", help="Telegram bot — deploy agents from chat") +app.add_typer(jobs_app, name="jobs", help="Perpetual agent jobs — register, run, and manage on-chain jobs") def main(): diff --git a/docs/agent_cli_jobs_spec.tex b/docs/agent_cli_jobs_spec.tex new file mode 100644 index 0000000..81dd9f4 --- /dev/null +++ b/docs/agent_cli_jobs_spec.tex @@ -0,0 +1,1305 @@ +\documentclass[11pt,a4paper]{article} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{geometry} +\usepackage{amsmath,amssymb} +\usepackage{booktabs} +\usepackage{longtable} +\usepackage{enumitem} +\usepackage{xcolor} +\usepackage{tcolorbox} +\usepackage{listings} +\usepackage{hyperref} +\usepackage{fancyhdr} +\usepackage{titlesec} +\usepackage{array} +\usepackage{multirow} +\usepackage{float} +\usepackage{mdframed} +\usepackage{colortbl} +\usepackage{tabularx} + +\geometry{margin=1in} +\hypersetup{colorlinks=true,linkcolor=blue,urlcolor=blue} + +\definecolor{critical}{RGB}{220,53,69} +\definecolor{warning}{RGB}{255,193,7} +\definecolor{success}{RGB}{40,167,69} +\definecolor{info}{RGB}{23,162,184} +\definecolor{codeblue}{RGB}{0,102,204} +\definecolor{codegray}{RGB}{128,128,128} +\definecolor{codebg}{RGB}{248,248,248} +\definecolor{nunchiblue}{HTML}{1a73e8} +\definecolor{nunchidark}{HTML}{202124} +\definecolor{cooperative}{RGB}{106,27,154} +\definecolor{keeper}{RGB}{2,119,189} +\definecolor{operator}{RGB}{230,81,0} +\definecolor{managed}{RGB}{46,125,50} + +\newtcolorbox{criticalbox}{colback=critical!10,colframe=critical,title=\textbf{CRITICAL}} +\newtcolorbox{warningbox}{colback=warning!10,colframe=warning!80!black,title=\textbf{WARNING}} +\newtcolorbox{infobox}{colback=info!10,colframe=info,title=\textbf{INFO}} +\newtcolorbox{successbox}{colback=success!10,colframe=success,title=\textbf{SUCCESS CRITERIA}} +\newtcolorbox{contractbox}{colback=codebg,colframe=codeblue,title=\textbf{Contract Interface}} +\newtcolorbox{flowbox}{colback=gray!5,colframe=gray!50!black,title=\textbf{Process Flow}} +\newtcolorbox{cooperativebox}{colback=cooperative!8,colframe=cooperative,title=\textbf{House Cooperative}} +\newtcolorbox{keeperbox}{colback=keeper!8,colframe=keeper,title=\textbf{Permissionless Keeper}} +\newtcolorbox{operatorbox}{colback=operator!8,colframe=operator,title=\textbf{Privileged Operator}} +\newtcolorbox{managedbox}{colback=managed!8,colframe=managed,title=\textbf{Managed Infrastructure}} +\newtcolorbox{designbox}[1][]{colback=nunchiblue!5,colframe=nunchiblue,title=\textbf{Design Decision: #1}} + +% CLI-specific box +\newtcolorbox{clibox}[1][]{colback=nunchidark!5,colframe=nunchidark,title=\textbf{CLI: #1}} + +\lstset{ + basicstyle=\ttfamily\small, + backgroundcolor=\color{codebg}, + keywordstyle=\color{codeblue}\bfseries, + commentstyle=\color{codegray}, + stringstyle=\color{success!80!black}, + breaklines=true, + frame=single, + rulecolor=\color{codegray}, + tabsize=2 +} + +\pagestyle{fancy} +\fancyhf{} +\rhead{Nunchi Trade -- Agent CLI Jobs} +\lhead{Specification v1.0} +\rfoot{Page \thepage} + +\title{\textbf{Agent CLI: Job Integration \& Assignment Routing}\\[0.5em] +\large Unified CLI for Perpetual Agent Jobs\\[0.3em] +\small Companion to: Perpetual Agent Jobs Specification v1.0} +\author{Nunchi Trade} +\date{March 2026} + +\begin{document} + +\maketitle + +\begin{center} +\fbox{\parbox{0.9\textwidth}{ +\textbf{Scope.} This document specifies how the \texttt{hl} CLI integrates with the +eight perpetual agent jobs defined in the Perpetual Agent Jobs Specification v1.0. +It covers the \texttt{hl jobs} command group, the engine hierarchy that dispatches +each job to the correct execution runtime, strategy interfaces for each job category, +event subscription, custody enforcement at the CLI layer, and the phased migration +plan from current-state scripts to a unified CLI-driven job system. +}} +\end{center} + +\tableofcontents +\newpage + +%============================================================================== +\section{Introduction and Motivation} +%============================================================================== + +The Nunchi agent runtime currently spans two disconnected execution surfaces: + +\begin{enumerate}[leftmargin=2em] + \item \textbf{The \texttt{hl} CLI} handles free-form Hyperliquid trading strategies. + A user runs \texttt{hl run avellaneda\_mm} to start a market-making loop that + polls the relay, generates quotes via a pluggable \texttt{BaseStrategy}, and + submits sealed decisions through the TEE-work commit--reveal pipeline. + \item \textbf{Standalone scripts} (\texttt{scripts/run\_agent.py}, + \texttt{scripts/run\_relay.py}) bootstrap the cooperative market-making + infrastructure separately. Keeper services such as oracle updates, funding + settlement, and liquidation flagging are handled by ad-hoc cron jobs or + manual invocations with no shared lifecycle management. +\end{enumerate} + +The Perpetual Agent Jobs Specification formalizes eight on-chain roles across four +categories---\textcolor{keeper}{\textbf{Keeper}}, \textcolor{operator}{\textbf{Operator}}, +\textcolor{cooperative}{\textbf{Cooperative}}, and \textcolor{managed}{\textbf{Managed}}---each +with explicit trigger conditions, custody constraints, staking requirements, and +reward mechanisms. This document specifies how the CLI becomes the \textbf{unified +interface} for all eight job types, from permissionless keepers to TEE-attested +cooperative agents. + +Three requirements drive the design: + +\begin{enumerate}[leftmargin=2em] + \item \textbf{Job integration.} Register, run, monitor, and claim rewards for any + of the 8 job types through a single CLI command group (\texttt{hl jobs}). + The CLI manages the full on-chain lifecycle: staking, heartbeats, reward + claims, and deregistration. + \item \textbf{Assignment routing.} Automatically dispatch each job to the correct + execution engine based on its category. A keeper job routes to the + \texttt{KeeperEngine} (event-driven, stateless transaction submission); + a cooperative job routes to the \texttt{CooperativeEngine} (relay-polling, + commit--reveal rounds); a managed job routes to the \texttt{ManagedEngine} + (periodic evaluation with capital actions). The user does not need to know + which engine handles their job---the factory resolves it from the job + definition. + \item \textbf{Extensibility.} Support new job types without modifying the routing + or engine infrastructure. Adding a new keeper (e.g., a cross-chain bridge + relayer) requires only a new \texttt{KeeperStrategy} implementation and a + registry entry. The engine, event subscription, and custody layers remain + unchanged. +\end{enumerate} + +\begin{infobox} +This spec is a companion to the Perpetual Agent Jobs Specification v1.0. That document +defines the jobs themselves (triggers, custody, rewards, slashing). This document +defines the CLI surface and execution infrastructure that runs them. +\end{infobox} + +%============================================================================== +\section{CLI Command Design} +%============================================================================== + +All job-related commands live under the \texttt{hl jobs} command group. Each command +maps to a specific phase of the job lifecycle. + +\begin{clibox}[hl jobs list] +\begin{lstlisting} +$ hl jobs list + + ID NAME CATEGORY TRIGGER MIN STAKE + oracle_updater Oracle Updater KEEPER NewBlock 10 HYPE + funding_keeper Funding Keeper KEEPER NewBlock 10 HYPE + liq_flagger Liq. Flagger KEEPER OracleUpdate 10 HYPE + liq_executor Liq. Executor OPERATOR PositionFlagged 50 HYPE + tpsl_agent TP/SL Agent OPERATOR OracleUpdate 25 HYPE + market_maker Market Maker COOPERATIVE RoundStart 100 HYPE + abm_agent ABM Agent COOPERATIVE OracleUpdate 100 HYPE + glv_manager GLV Manager MANAGED Periodic+Event 50 HYPE +\end{lstlisting} +Lists all job types from the local registry, showing category, trigger type, and minimum stake. +\end{clibox} + +\begin{clibox}[hl jobs info] +\begin{lstlisting} +$ hl jobs info oracle_updater + + Job: Oracle Updater (oracle_updater) + Category: KEEPER (Permissionless) + Trigger: NewBlock + Custody: OracleManager.updatePriceFeeds only + Min Stake: 10 HYPE + Reward: 0.002 HYPE per successful update + Slashing: Liveness (miss 100 consecutive blocks) + TEE: Not required + Status: Not registered +\end{lstlisting} +Shows full details for a specific job: trigger conditions, custody constraints, +staking and reward parameters, slashing conditions, and current registration status. +\end{clibox} + +\begin{clibox}[hl jobs register] +\begin{lstlisting} +$ hl jobs register oracle_updater --stake 15 + + Registering for job: Oracle Updater + Staking: 15 HYPE (min: 10 HYPE) + TEE attestation: not required + Transaction: 0xabc123... + Status: REGISTERED +\end{lstlisting} +Registers the caller for a job on-chain. Stakes the specified amount (must meet +minimum). If \texttt{-{}-tee} is passed, generates and submits a TEE attestation +as part of registration. +\end{clibox} + +\begin{clibox}[hl jobs run] +\begin{lstlisting} +$ hl jobs run oracle_updater --config jobs/oracle.yaml --mainnet + + Loading config: jobs/oracle.yaml + Job: oracle_updater -> KeeperEngine + Subscribing to: NewBlock events + Custody policy: OracleManager.updatePriceFeeds + Engine started. Press Ctrl+C to stop. + [12:00:01] Block 1234: staleness 47s > 45s, submitting update... + [12:00:01] tx 0xdef456 submitted (gas: 142000) + [12:00:13] Block 1235: staleness 1s, skipping. +\end{lstlisting} +Starts the job engine. The CLI loads the YAML config, looks up the job definition +in the registry, validates requirements (role, TEE, minimum stake), creates the +appropriate engine via \texttt{JobEngineFactory}, and enters the execution loop. +Supports \texttt{-{}-dry-run} for local simulation without submitting transactions. +\end{clibox} + +\begin{clibox}[hl jobs status] +\begin{lstlisting} +$ hl jobs status + + RUNNING JOBS: + ID ENGINE UPTIME HEARTBEAT REWARDS + oracle_updater KeeperEngine 2h 14m 12s ago 1.24 HYPE + market_maker CoopEngine 1h 02m 3s ago 8.71 HYPE +\end{lstlisting} +Shows running jobs, their engine type, uptime, last heartbeat, and accumulated rewards. +Use \texttt{-{}-job-id } to filter to a specific job. +\end{clibox} + +\begin{clibox}[hl jobs claim] +\begin{lstlisting} +$ hl jobs claim oracle_updater + + Claiming rewards for: Oracle Updater + Accumulated: 1.24 HYPE + Transaction: 0x789abc... + Claimed: 1.24 HYPE -> 0xYourAddress +\end{lstlisting} +Claims accumulated rewards for a job from the on-chain JobRegistry contract. +\end{clibox} + +\begin{clibox}[hl jobs deregister] +\begin{lstlisting} +$ hl jobs deregister oracle_updater + + Deregistering from: Oracle Updater + Unstaking: 15 HYPE (cooldown: 7 days) + Transaction: 0xfed321... + Status: DEREGISTERED (stake unlocks at block 98765) +\end{lstlisting} +Deregisters from a job and initiates the unstaking cooldown. The staked amount +is returned after the cooldown period. +\end{clibox} + +\begin{designbox}[Why \texttt{hl jobs} is separate from \texttt{hl run}] +The existing \texttt{hl run } command starts a free-form trading strategy +with no on-chain lifecycle. There is no registration, no staking, no heartbeat +obligation, no slashing risk, and no custody enforcement. The user simply picks a +strategy and runs it against Hyperliquid. + +Jobs are fundamentally different: they have an \textbf{on-chain lifecycle} +(registration, staking, heartbeats, slashing, custody enforcement) that trading +strategies do not need. Merging these into a single command would force trading +users to deal with staking and registration, and force job operators to skip +lifecycle steps they actually need. The \texttt{hl jobs} command group makes +the lifecycle explicit while reusing the same strategy infrastructure underneath. + +A cooperative job (\texttt{hl jobs run market\_maker}) and a free-form strategy +(\texttt{hl run avellaneda\_mm}) may even use the same \texttt{BaseStrategy} +subclass, but the job version wraps it with heartbeats, custody validation, and +reward tracking. +\end{designbox} + +\subsection{Job Configuration (YAML)} + +Each job is configured via a YAML file passed to \texttt{hl jobs run -{}-config}. + +\begin{keeperbox} +\textbf{Example: Keeper job configuration (oracle\_updater)} +\begin{lstlisting}[language={}] +job_id: oracle_updater +agent_id: agent-oracle-1 +chain_rpc: https://rpc.nunchi.trade +event_bus_ws: wss://events.nunchi.trade/ws +strategy: jobs.keepers.oracle:OracleKeeperStrategy +strategy_params: + staleness_threshold_s: 45 + deviation_threshold_pct: 0.1 + pyth_hermes_url: https://hermes.pyth.network +mainnet: true +data_dir: data/jobs/oracle +\end{lstlisting} +\end{keeperbox} + +\begin{cooperativebox} +\textbf{Example: Cooperative job configuration (market\_maker)} +\begin{lstlisting}[language={}] +job_id: market_maker +agent_id: agent-mm-1 +relay_url: http://relay.nunchi.trade:8080 +strategy: strategies.avellaneda_mm:AvellanedaStoikovMM +strategy_params: + gamma: 0.1 + k: 4.0 + base_size: 0.05 +tee_enabled: true +stake_amount: 100.0 +risk: + max_position_qty: 0.5 + max_leverage: 2.0 + max_daily_drawdown_pct: 2.0 +data_dir: data/jobs/mm +\end{lstlisting} +\end{cooperativebox} + +%============================================================================== +\section{Architecture --- Engine Hierarchy} +%============================================================================== + +The engine hierarchy is the core dispatch mechanism. Each job category maps to +exactly one engine type. All engines implement the same \texttt{JobEngine} abstract +base class, ensuring uniform lifecycle management regardless of the underlying +execution model. + +\begin{contractbox} +\textbf{Engine Hierarchy} +\begin{lstlisting}[language={}] +JobEngine (ABC) + start(job_def, config) -> None + stop() -> None + heartbeat() -> None + status() -> JobStatus + | + +-- KeeperEngine # KEEPER + OPERATOR jobs + | EventSubscriber -> condition check -> CustodyGuard + | -> tx submit -> heartbeat + | + +-- CooperativeEngine # COOPERATIVE jobs (wraps AgentClient) + | Relay poll -> strategy tick -> seal -> commit + | -> reveal -> feedback -> heartbeat + | + +-- ManagedEngine # MANAGED jobs + Timer + EventSubscriber -> condition check + -> CustodyGuard -> tx submit +\end{lstlisting} +\end{contractbox} + +\textbf{KeeperEngine} handles both \textcolor{keeper}{Keeper} and +\textcolor{operator}{Operator} jobs. These are event-driven: the engine subscribes +to chain events (new blocks, oracle updates, position flags), evaluates a +\texttt{KeeperStrategy} on each event, and submits transactions when the strategy +returns a non-null result. The \texttt{CustodyGuard} validates every transaction +against the job's custody policy before signing. + +\textbf{CooperativeEngine} handles \textcolor{cooperative}{Cooperative} jobs. It +wraps the existing \texttt{AgentClient} from the tee-work-llm repository, adding +heartbeat reporting and reward tracking. The execution model is relay-driven: +poll for a clearing round, run the strategy tick, seal the decision, commit, reveal, +and receive feedback. + +\textbf{ManagedEngine} handles \textcolor{managed}{Managed} jobs. It combines +periodic timers with event subscriptions. The \texttt{ManagedStrategy} evaluates +vault state on each tick and returns a list of capital actions (rebalance, harvest, +process withdrawals). Like \texttt{KeeperEngine}, it enforces custody constraints +before transaction submission. + +\subsection{Factory Dispatch} + +The \texttt{JobEngineFactory} maps job categories to engine types. This is the +single point of routing logic---adding a new category requires only a new match arm. + +\begin{contractbox} +\textbf{JobEngineFactory} +\begin{lstlisting}[language=Python] +class JobEngineFactory: + @staticmethod + def create(job_def: JobDefinition) -> JobEngine: + match job_def.category: + case JobCategory.KEEPER | JobCategory.OPERATOR: + return KeeperEngine(job_def) + case JobCategory.COOPERATIVE: + return CooperativeEngine(job_def) + case JobCategory.MANAGED: + return ManagedEngine(job_def) + case _: + raise ValueError( + f"Unknown job category: {job_def.category}" + ) +\end{lstlisting} +\end{contractbox} + +\subsection{Full Dispatch Flow} + +\begin{flowbox} +\textbf{\texttt{hl jobs run -{}-config }} +\begin{enumerate}[leftmargin=2em] + \item Load \texttt{JobConfig} from the YAML configuration file. + \item Look up \texttt{job\_id} in \texttt{JOB\_REGISTRY} to obtain the + \texttt{JobDefinition} (category, trigger, custody policy, staking + requirements). + \item \textbf{Validate}: check that the caller meets role requirements (e.g., + \texttt{OPERATOR\_ROLE} for privileged jobs), TEE requirements (attestation + present if \texttt{requires\_tee} is true), and minimum stake (on-chain + registration with sufficient stake). + \item \texttt{JobEngineFactory.create(job\_def)} returns the appropriate engine + instance. + \item \texttt{engine.start(job\_def, config)} initializes event subscriptions, + loads the strategy, and enters the execution loop. + \item Register signal handlers (\texttt{SIGINT}, \texttt{SIGTERM}) for graceful + shutdown. + \item On signal: \texttt{engine.stop()} flushes pending state, sends a final + heartbeat, and exits cleanly. +\end{enumerate} +\end{flowbox} + +%============================================================================== +\section{Strategy Interfaces} +%============================================================================== + +Each engine type consumes a different strategy abstract base class. The strategy +is the user-authored component: it encapsulates the decision logic while the engine +handles lifecycle, event routing, custody, and heartbeats. + +\subsection{KeeperStrategy} + +\begin{contractbox} +\textbf{KeeperStrategy --- for Keeper and Operator jobs} +\begin{lstlisting}[language=Python] +class KeeperStrategy(ABC): + """Event-driven, stateless decisions. + + Receives a chain event and context, returns either a + Transaction to submit or None to skip this event. + """ + + @abstractmethod + def should_execute( + self, + event: ChainEvent, + context: KeeperContext, + ) -> Optional[Transaction]: + """Evaluate the event against current on-chain state. + + Args: + event: The chain event that triggered evaluation + (new block, oracle update, position flag). + context: Current on-chain state relevant to this + job (price ages, margin ratios, etc.). + + Returns: + A Transaction to submit, or None to skip. + """ + ... +\end{lstlisting} +\end{contractbox} + +\textbf{KeeperContext} contains: +\begin{itemize}[leftmargin=2em] + \item \texttt{event}: the \texttt{ChainEvent} that triggered evaluation (block number, event type, raw log data). + \item \texttt{on\_chain\_state}: current state relevant to the job---e.g., oracle price age and deviation for the oracle updater, flagged position list for the liquidation executor, margin ratios for the liquidation flagger. + \item \texttt{gas\_estimate}: estimated gas cost for the transaction, used by the strategy to decide whether execution is profitable given current reward rates. +\end{itemize} + +\subsection{CooperativeStrategy} + +\begin{contractbox} +\textbf{CooperativeStrategy --- for House Cooperative jobs} +\begin{lstlisting}[language=Python] +class CooperativeStrategy(BaseStrategy): + """Extends BaseStrategy with round feedback. + + Inherits the core tick() -> List[StrategyDecision] + interface from BaseStrategy. Adds on_round_result() + for post-clearing adaptive learning. + """ + + def on_round_result( + self, + result: RoundResult, + ) -> None: + """Receive post-clearing feedback. + + Called after the clearing engine resolves the round. + Contains fill information, PnL attribution, and + counterparty flow data for adaptive learning. + + Args: + result: Clearing outcome including fills, + realized PnL, and inventory delta. + """ + pass +\end{lstlisting} +\end{contractbox} + +\textbf{CooperativeStrategy} extends the existing \texttt{BaseStrategy} from the +agent runtime. The inherited \texttt{tick()} method receives a \texttt{StrategyContext} +(current position, PnL, market snapshot) and returns a list of \texttt{StrategyDecision} +objects (quotes, hedges). The added \texttt{on\_round\_result()} callback enables +strategies to learn from clearing outcomes---adjusting spread, inventory targets, +or aggressiveness based on realized fills and adverse selection. + +\subsection{ManagedStrategy} + +\begin{contractbox} +\textbf{ManagedStrategy --- for Managed Infrastructure jobs} +\begin{lstlisting}[language=Python] +class ManagedStrategy(ABC): + """Periodic evaluation with capital management actions. + + Evaluates vault state on each tick and returns a list + of capital actions to execute (rebalance, harvest fees, + process pending withdrawals). + """ + + @abstractmethod + def evaluate( + self, + context: ManagedContext, + ) -> List[CapitalAction]: + """Evaluate current state and return actions. + + Args: + context: Current vault state including balances, + pending withdrawals, total assets, and + last harvest timestamp. + + Returns: + List of CapitalAction objects to execute. + Empty list means no action needed. + """ + ... +\end{lstlisting} +\end{contractbox} + +\textbf{ManagedContext} contains: +\begin{itemize}[leftmargin=2em] + \item \texttt{vault\_balance}: current idle balance in the vault contract. + \item \texttt{pending\_withdrawals}: list of withdrawal requests awaiting processing, each with amount and requester. + \item \texttt{total\_assets}: total assets under management (idle + deployed in strategy). + \item \texttt{last\_harvest\_timestamp}: Unix timestamp of the last performance fee harvest, used to determine if a new harvest is due. +\end{itemize} + +\subsection{Job-to-Strategy Mapping} + +\begin{table}[H] +\centering +\small +\begin{tabularx}{\textwidth}{l l l X} +\toprule +\textbf{Job} & \textbf{Strategy ABC} & \textbf{Context Model} & \textbf{Decision Output} \\ +\midrule +\rowcolor{keeper!8} +Oracle Updater & KeeperStrategy & KeeperContext (price age, Pyth data) & Transaction (\texttt{updatePriceFeeds}) \\ +\rowcolor{keeper!8} +Funding Keeper & KeeperStrategy & KeeperContext (window state) & Transaction (\texttt{settleFundingWindow}) \\ +\rowcolor{keeper!8} +Liq.\ Flagger & KeeperStrategy & KeeperContext (positions, margins) & Transaction (\texttt{flagPosition}) \\ +\rowcolor{operator!8} +Liq.\ Executor & KeeperStrategy & KeeperContext (flagged positions) & Transaction (\texttt{liquidatePosition}) \\ +\rowcolor{operator!8} +TP/SL Agent & KeeperStrategy & KeeperContext (conditional orders, prices) & Transaction (\texttt{placeOrder} REDUCE\_ONLY) \\ +\rowcolor{cooperative!8} +Market Maker & CooperativeStrategy & StrategyContext (position, PnL) & List[StrategyDecision] \\ +\rowcolor{cooperative!8} +ABM Agent & CooperativeStrategy & StrategyContext + bin context & List[StrategyDecision] \\ +\rowcolor{managed!8} +GLV Manager & ManagedStrategy & ManagedContext (vault, withdrawals) & List[CapitalAction] \\ +\bottomrule +\end{tabularx} +\caption{Mapping of each job to its strategy interface, context model, and output type.} +\end{table} + +%============================================================================== +\section{Event Subscription Model} +%============================================================================== + +Engines receive triggers through an \texttt{EventSubscriber} abstraction. This +decouples the engine from the specific event transport---whether that is a WebSocket +connection to the canonical chain event bus or a fallback polling mechanism against +EVM \texttt{eth\_getLogs}. + +\begin{contractbox} +\textbf{EventSubscriber ABC} +\begin{lstlisting}[language=Python] +class EventSubscriber(ABC): + """Abstract event source for job engines.""" + + @abstractmethod + async def subscribe( + self, + event_types: List[str], + callback: Callable[[ChainEvent], Awaitable[None]], + ) -> None: + """Subscribe to one or more event types. + + Args: + event_types: List of event type strings + (e.g., ["NewBlock", "OracleUpdate"]). + callback: Async function called on each event. + """ + ... + + @abstractmethod + async def unsubscribe(self) -> None: + """Cleanly disconnect and release resources.""" + ... +\end{lstlisting} +\end{contractbox} + +\subsection{Implementations} + +\begin{contractbox} +\textbf{ChainEventSubscriber} +\begin{lstlisting}[language=Python] +class ChainEventSubscriber(EventSubscriber): + """WebSocket connection to canonical chain event bus. + + Connects to the chain event bus via WebSocket, subscribes + to requested event types, and dispatches ChainEvent objects + to the callback. Handles reconnection with exponential + backoff. Target implementation for L1 mainnet. + """ + + def __init__(self, ws_url: str): ... + + async def subscribe(self, event_types, callback): ... + async def unsubscribe(self): ... +\end{lstlisting} +\end{contractbox} + +\begin{contractbox} +\textbf{LogPollingSubscriber} +\begin{lstlisting}[language=Python] +class LogPollingSubscriber(EventSubscriber): + """Fallback: polls eth_getLogs for contract events. + + For V1 contracts on HyperEVM before chain event bus is + available. Polls at a configurable interval (default: 2s), + converts raw logs into ChainEvent objects, and dispatches + to the callback. Less efficient but works with any + EVM-compatible RPC endpoint. + """ + + def __init__(self, rpc_url: str, poll_interval_s: float = 2.0): ... + + async def subscribe(self, event_types, callback): ... + async def unsubscribe(self): ... +\end{lstlisting} +\end{contractbox} + +\subsection{Event Type Mapping} + +Each job subscribes to specific event types based on its trigger condition. The +engine resolves the correct event types from the \texttt{JobDefinition} and passes +them to the subscriber. + +\begin{table}[H] +\centering +\small +\begin{tabularx}{\textwidth}{l X} +\toprule +\textbf{Job} & \textbf{Event Subscription} \\ +\midrule +\rowcolor{keeper!8} +Oracle Updater & \texttt{NewBlock} --- evaluate staleness on every new block \\ +\rowcolor{keeper!8} +Funding Keeper & \texttt{NewBlock} + window-end check --- evaluate on each block whether the current funding window has ended \\ +\rowcolor{keeper!8} +Liq.\ Flagger & \texttt{OracleUpdate} --- re-evaluate margin ratios after each oracle price update \\ +\rowcolor{operator!8} +Liq.\ Executor & \texttt{PositionFlagged} --- act immediately when a position is flagged for liquidation \\ +\rowcolor{operator!8} +TP/SL Agent & \texttt{OracleUpdate} --- check conditional order trigger prices after each oracle update \\ +\rowcolor{cooperative!8} +Market Maker & \texttt{ClearingRoundStart} (from relay, not chain) --- poll relay for new clearing rounds \\ +\rowcolor{cooperative!8} +ABM Agent & \texttt{OracleUpdate} + deviation threshold --- re-evaluate bin placement when price moves beyond threshold \\ +\rowcolor{managed!8} +GLV Manager & \texttt{WithdrawRequested} + periodic timer --- respond to withdrawal requests and periodically harvest fees \\ +\bottomrule +\end{tabularx} +\caption{Event subscriptions per job. Cooperative jobs receive triggers from the relay rather than the chain event bus.} +\end{table} + +\begin{designbox}[Two-Phase Event Transport] +The \texttt{EventSubscriber} ABC abstracts the event transport so that engines are +agnostic to the source. This enables a two-phase rollout: + +\textbf{Phase 1 (V1 contracts on HyperEVM):} The \texttt{LogPollingSubscriber} +polls \texttt{eth\_getLogs} against deployed V1 contracts. This is functional but +inefficient---it introduces latency proportional to the polling interval and +generates unnecessary RPC load. It serves as the fallback for early deployments +before the chain event bus is operational. + +\textbf{Phase 2 (L1 mainnet):} The \texttt{ChainEventSubscriber} connects to +the canonical event bus via WebSocket. Events are pushed in real-time with sub-second +latency. The engine code is unchanged---only the subscriber implementation swapped +via configuration. + +This abstraction also supports testing: a \texttt{MockEventSubscriber} can inject +synthetic events for deterministic engine testing without a live chain. +\end{designbox} + +%============================================================================== +\section{Custody Enforcement} +%============================================================================== + +Custody enforcement at the CLI layer is defense-in-depth. The on-chain contracts +enforce role checks and selector validation independently, but the CLI-side +\texttt{CustodyGuard} catches mistakes \emph{before} they reach the chain---preventing +wasted gas on transactions that would revert. + +\begin{contractbox} +\textbf{CustodyGuard} +\begin{lstlisting}[language=Python] +class CustodyGuard: + """Validates transactions against a job's custody policy + before signing and submitting.""" + + def __init__(self, policy: CustodyPolicy): + self._policy = policy + self._tx_count_per_block: Dict[int, int] = {} + + def validate(self, tx: Transaction) -> bool: + """Validate a transaction against the custody policy. + + Checks: + 1. tx.to in policy.destinations + (only allowed contract addresses) + 2. tx.data[:4] in policy.selectors + (only allowed function selectors) + 3. tx.value <= policy.value_cap + (native value transfer cap) + 4. Rate limit: txs per block does not exceed + policy.max_txs_per_block + + Returns True if all checks pass, False otherwise. + Logs the specific violation on failure. + """ + # 1. Destination whitelist + if tx.to not in self._policy.destinations: + logger.warning( + f"CustodyGuard: tx.to {tx.to} not in " + f"allowed destinations" + ) + return False + + # 2. Function selector whitelist + selector = tx.data[:4] + if selector not in self._policy.selectors: + logger.warning( + f"CustodyGuard: selector {selector.hex()} " + f"not in allowed selectors" + ) + return False + + # 3. Value cap + if tx.value > self._policy.value_cap: + logger.warning( + f"CustodyGuard: tx.value {tx.value} " + f"exceeds cap {self._policy.value_cap}" + ) + return False + + # 4. Rate limit + block = tx.block_number + count = self._tx_count_per_block.get(block, 0) + if count >= self._policy.max_txs_per_block: + logger.warning( + f"CustodyGuard: rate limit exceeded " + f"for block {block}" + ) + return False + self._tx_count_per_block[block] = count + 1 + + return True +\end{lstlisting} +\end{contractbox} + +\begin{criticalbox} +Custody enforcement is defense-in-depth. The on-chain contracts also enforce role +checks and selector validation via the \texttt{RoleRegistry} and per-function access +control modifiers. The CLI-side \texttt{CustodyGuard} catches mistakes early---wrong +contract address, wrong function selector, excessive value transfers---before wasting +gas on a transaction that would revert on-chain. Both layers must agree for a +transaction to succeed. Neither layer alone is sufficient: the on-chain layer cannot +prevent gas waste, and the CLI layer cannot prevent a malicious binary from bypassing it. +\end{criticalbox} + +\subsection{Custody Policy per Job} + +Each job definition includes a \texttt{CustodyPolicy} specifying the exact contracts, +selectors, value caps, and rate limits allowed. + +\begin{table}[H] +\centering +\small +\begin{tabularx}{\textwidth}{l l l c} +\toprule +\textbf{Job} & \textbf{Allowed Destinations} & \textbf{Allowed Selectors} & \textbf{Value Cap} \\ +\midrule +\rowcolor{keeper!8} +Oracle Updater & OracleManager & \texttt{updatePriceFeeds} & 0 \\ +\rowcolor{keeper!8} +Funding Keeper & ClearingHouse & \texttt{settleFundingWindow} & 0 \\ +\rowcolor{keeper!8} +Liq.\ Flagger & ClearingHouse & \texttt{flagPosition} & 0 \\ +\rowcolor{operator!8} +Liq.\ Executor & ClearingHouse & \texttt{liquidatePosition} & 0 \\ +\rowcolor{operator!8} +TP/SL Agent & ClearingHouse & \texttt{placeOrder} (REDUCE\_ONLY) & 0 \\ +\rowcolor{cooperative!8} +Market Maker & Relay (off-chain) & commit, reveal & 0 \\ +\rowcolor{cooperative!8} +ABM Agent & Relay + BinManager & commit, reveal, \texttt{recenterBins} & 0 \\ +\rowcolor{managed!8} +GLV Manager & Glv & \texttt{deposit}, \texttt{withdraw}, \texttt{harvestPerformanceFee} & 0 \\ +\bottomrule +\end{tabularx} +\caption{Custody policies per job. All jobs have a native value cap of 0 (no ETH/HYPE transfers). Cooperative jobs route through the relay for commit/reveal and do not submit on-chain transactions directly except for ABM bin recentering.} +\end{table} + +%============================================================================== +\section{Job Registry (Local + On-Chain)} +%============================================================================== + +The job registry exists in two layers: a local registry embedded in the CLI for +offline job discovery and validation, and an on-chain registry contract for +registration, staking, heartbeats, and reward distribution. + +\subsection{Local Registry} + +The local \texttt{JOB\_REGISTRY} mirrors the pattern established by +\texttt{strategy\_registry.py} in the agent runtime. It provides job metadata +for CLI commands (\texttt{hl jobs list}, \texttt{hl jobs info}) and validation +logic (\texttt{hl jobs run}) without requiring a chain connection. + +\begin{contractbox} +\textbf{JOB\_REGISTRY} +\begin{lstlisting}[language=Python] +JOB_REGISTRY: Dict[str, JobDefinition] = { + "oracle_updater": JobDefinition( + job_id="oracle_updater", + name="Oracle Updater", + category=JobCategory.KEEPER, + trigger=TriggerType.NEW_BLOCK, + custody=CustodyPolicy( + destinations=["OracleManager"], + selectors=["updatePriceFeeds"], + value_cap=0, + max_txs_per_block=1, + ), + min_stake=10, + requires_tee=False, + ), + "funding_keeper": JobDefinition( + job_id="funding_keeper", + name="Funding Keeper", + category=JobCategory.KEEPER, + trigger=TriggerType.NEW_BLOCK, + custody=CustodyPolicy( + destinations=["ClearingHouse"], + selectors=["settleFundingWindow"], + value_cap=0, + max_txs_per_block=1, + ), + min_stake=10, + requires_tee=False, + ), + "liq_flagger": JobDefinition( + job_id="liq_flagger", + name="Liquidation Flagger", + category=JobCategory.KEEPER, + trigger=TriggerType.ORACLE_UPDATE, + custody=CustodyPolicy( + destinations=["ClearingHouse"], + selectors=["flagPosition"], + value_cap=0, + max_txs_per_block=5, + ), + min_stake=10, + requires_tee=False, + ), + "liq_executor": JobDefinition( + job_id="liq_executor", + name="Liquidation Executor", + category=JobCategory.OPERATOR, + trigger=TriggerType.POSITION_FLAGGED, + custody=CustodyPolicy( + destinations=["ClearingHouse"], + selectors=["liquidatePosition"], + value_cap=0, + max_txs_per_block=3, + ), + min_stake=50, + requires_tee=False, + ), + "tpsl_agent": JobDefinition( + job_id="tpsl_agent", + name="TP/SL Agent", + category=JobCategory.OPERATOR, + trigger=TriggerType.ORACLE_UPDATE, + custody=CustodyPolicy( + destinations=["ClearingHouse"], + selectors=["placeOrder"], + value_cap=0, + max_txs_per_block=10, + ), + min_stake=25, + requires_tee=False, + ), + "market_maker": JobDefinition( + job_id="market_maker", + name="Market Maker", + category=JobCategory.COOPERATIVE, + trigger=TriggerType.ROUND_START, + custody=CustodyPolicy( + destinations=["Relay"], + selectors=["commit", "reveal"], + value_cap=0, + max_txs_per_block=1, + ), + min_stake=100, + requires_tee=True, + ), + "abm_agent": JobDefinition( + job_id="abm_agent", + name="ABM Agent", + category=JobCategory.COOPERATIVE, + trigger=TriggerType.ORACLE_UPDATE, + custody=CustodyPolicy( + destinations=["Relay", "BinManager"], + selectors=[ + "commit", "reveal", "recenterBins", + ], + value_cap=0, + max_txs_per_block=2, + ), + min_stake=100, + requires_tee=True, + ), + "glv_manager": JobDefinition( + job_id="glv_manager", + name="GLV Manager", + category=JobCategory.MANAGED, + trigger=TriggerType.PERIODIC_AND_EVENT, + custody=CustodyPolicy( + destinations=["Glv"], + selectors=[ + "deposit", "withdraw", + "harvestPerformanceFee", + ], + value_cap=0, + max_txs_per_block=2, + ), + min_stake=50, + requires_tee=False, + ), +} +\end{lstlisting} +\end{contractbox} + +\subsection{On-Chain Registry Client} + +The on-chain \texttt{JobRegistry} contract manages agent registration, staking, +heartbeat tracking, reward accrual, and slashing. The CLI interacts with it through +a \texttt{JobRegistryClient} abstraction that supports both mock and live +implementations. + +\begin{contractbox} +\textbf{JobRegistryClient ABC} +\begin{lstlisting}[language=Python] +class JobRegistryClient(ABC): + """Interface to the on-chain JobRegistry contract.""" + + @abstractmethod + def register( + self, + job_id: str, + stake: int, + attestation: bytes, + ) -> str: + """Register for a job with stake and optional + TEE attestation. Returns transaction hash.""" + ... + + @abstractmethod + def heartbeat(self, job_id: str) -> None: + """Submit a heartbeat proving liveness.""" + ... + + @abstractmethod + def claim_reward(self, job_id: str) -> int: + """Claim accumulated rewards. + Returns amount claimed in wei.""" + ... + + @abstractmethod + def deregister(self, job_id: str) -> None: + """Deregister and initiate unstaking cooldown.""" + ... +\end{lstlisting} +\end{contractbox} + +\begin{contractbox} +\textbf{MockJobRegistryClient} +\begin{lstlisting}[language=Python] +class MockJobRegistryClient(JobRegistryClient): + """For development before JobRegistry contract is + deployed. Stores state in-memory. Heartbeats are + no-ops. Rewards accrue at a fixed rate per call. + Used in --dry-run mode and local development.""" + + def __init__(self): + self._registered: Dict[str, int] = {} + self._rewards: Dict[str, int] = {} + + def register(self, job_id, stake, attestation): + self._registered[job_id] = stake + self._rewards[job_id] = 0 + return "0x" + "0" * 64 # mock tx hash + + def heartbeat(self, job_id): + self._rewards[job_id] = ( + self._rewards.get(job_id, 0) + 1 + ) + + def claim_reward(self, job_id): + amount = self._rewards.get(job_id, 0) + self._rewards[job_id] = 0 + return amount + + def deregister(self, job_id): + self._registered.pop(job_id, None) +\end{lstlisting} +\end{contractbox} + +\begin{contractbox} +\textbf{Web3JobRegistryClient} +\begin{lstlisting}[language=Python] +class Web3JobRegistryClient(JobRegistryClient): + """Interacts with deployed JobRegistry contract via + web3.py. Reads contract address and ABI from the + deployments registry. Handles transaction signing, + gas estimation, and confirmation waiting.""" + + def __init__( + self, + rpc_url: str, + registry_address: str, + signer: Account, + ): + self._w3 = Web3(Web3.HTTPProvider(rpc_url)) + self._contract = self._w3.eth.contract( + address=registry_address, + abi=self._load_abi(), + ) + self._signer = signer + + def register(self, job_id, stake, attestation): + tx = self._contract.functions.register( + job_id, attestation + ).build_transaction({ + "value": stake, + "from": self._signer.address, + }) + signed = self._signer.sign_transaction(tx) + tx_hash = self._w3.eth.send_raw_transaction( + signed.raw_transaction + ) + return tx_hash.hex() + + def heartbeat(self, job_id): + tx = self._contract.functions.heartbeat( + job_id + ).build_transaction({ + "from": self._signer.address, + }) + signed = self._signer.sign_transaction(tx) + self._w3.eth.send_raw_transaction( + signed.raw_transaction + ) + + def claim_reward(self, job_id): + tx = self._contract.functions.claimReward( + job_id + ).build_transaction({ + "from": self._signer.address, + }) + signed = self._signer.sign_transaction(tx) + receipt = self._w3.eth.send_raw_transaction( + signed.raw_transaction + ) + # Parse reward amount from event logs + return self._parse_reward_claimed(receipt) + + def deregister(self, job_id): + tx = self._contract.functions.deregister( + job_id + ).build_transaction({ + "from": self._signer.address, + }) + signed = self._signer.sign_transaction(tx) + self._w3.eth.send_raw_transaction( + signed.raw_transaction + ) +\end{lstlisting} +\end{contractbox} + +%============================================================================== +\section{Cooperative Engine Bridge} +%============================================================================== + +The \texttt{CooperativeEngine} bridges the job system with the existing TEE-work +cooperative market-making infrastructure. Rather than reimplementing the relay +polling, commit--reveal, and clearing feedback loop, it wraps the \texttt{AgentClient} +from the tee-work-llm repository. + +\begin{contractbox} +\textbf{CooperativeEngine} +\begin{lstlisting}[language=Python] +class CooperativeEngine(JobEngine): + """Engine for COOPERATIVE jobs. + + Wraps tee-work-llm's AgentClient, adding job lifecycle + management (heartbeats, reward tracking, graceful + shutdown) on top of the existing relay-based cooperative + execution model. + """ + + def start(self, job_def: JobDefinition, config: JobConfig): + from agent.client import AgentClient + + # Load the strategy (e.g., AvellanedaStoikovMM) + strategy = load_strategy(config.strategy) + + # Initialize the AgentClient with job config + self._client = AgentClient( + agent_id=config.agent_id, + strategy=strategy, + relay_url=config.relay_url, + ) + + # TEE attestation if required + if job_def.requires_tee: + self._attest() + + # Start heartbeat background task + self._heartbeat_task = asyncio.create_task( + self._heartbeat_loop() + ) + + # Enter the cooperative execution loop + # (poll relay -> tick -> seal -> commit -> reveal) + self._client.run() + + def stop(self): + self._heartbeat_task.cancel() + self._client.shutdown() + + def heartbeat(self): + self._registry_client.heartbeat( + self._job_def.job_id + ) + + def status(self) -> JobStatus: + return JobStatus( + job_id=self._job_def.job_id, + engine="CooperativeEngine", + running=self._client.is_running(), + uptime=self._uptime(), + last_heartbeat=self._last_heartbeat, + rewards=self._accumulated_rewards, + ) + + async def _heartbeat_loop(self): + """Send heartbeats at regular intervals.""" + while True: + await asyncio.sleep(30) + self.heartbeat() + self._last_heartbeat = time.time() +\end{lstlisting} +\end{contractbox} + +\begin{designbox}[Shared Models Between Repositories] +The \texttt{CooperativeEngine} imports \texttt{AgentClient} from the tee-work-llm +repository. This import is safe because both repositories share identical data +models: \texttt{MarketSnapshot}, \texttt{StrategyDecision}, and \texttt{BaseStrategy} +are defined in tee-work-llm and consumed by the agent-cli. + +Currently, these models are duplicated or imported via path manipulation. Long-term, +they should be extracted into a standalone \texttt{nunchi-sdk} package that both +repositories depend on. This eliminates version drift and makes the dependency +explicit in each project's \texttt{pyproject.toml}. + +Until \texttt{nunchi-sdk} is published, the agent-cli lists tee-work-llm as a +development dependency and imports directly. The CI pipeline verifies model +compatibility by running cross-repository integration tests. +\end{designbox} + +%============================================================================== +\section{File Structure} +%============================================================================== + +The job integration code lives under \texttt{agent-cli/cli/jobs/}. Each module +has a single responsibility, matching the architecture sections above. + +\begin{lstlisting}[language={}] +agent-cli/cli/jobs/ + __init__.py # Package init, exports command group + commands.py # Typer command group (list, info, + # register, run, status, claim, + # deregister) + registry.py # JOB_REGISTRY dict + JobDefinition + # dataclass + JobCategory enum + config.py # JobConfig dataclass (parsed from YAML) + engines.py # JobEngine ABC + KeeperEngine + + # CooperativeEngine + ManagedEngine + + # JobEngineFactory + events.py # ChainEvent dataclass + EventSubscriber + # ABC + ChainEventSubscriber + + # LogPollingSubscriber + custody.py # CustodyPolicy dataclass + CustodyGuard + strategy_interfaces.py # KeeperStrategy + CooperativeStrategy + + # ManagedStrategy ABCs + context models + status.py # JobStatus dataclass + heartbeat/reward + # tracking utilities +\end{lstlisting} + +Integration with the existing CLI requires a two-line change to \texttt{cli/main.py}: + +\begin{lstlisting}[language=Python] +# cli/main.py +from cli.jobs.commands import jobs_app + +app.add_typer(jobs_app, name="jobs", help="Manage perpetual agent jobs") +\end{lstlisting} + +%============================================================================== +\section{Migration and Phasing} +%============================================================================== + +The CLI job integration rolls out in four phases, aligned with the migration plan +from the Perpetual Agent Jobs Specification. + +\begin{table}[H] +\centering +\small +\begin{tabularx}{\textwidth}{l l X} +\toprule +\textbf{Phase} & \textbf{Infrastructure} & \textbf{CLI Capability} \\ +\midrule +\rowcolor{gray!10} +Phase 1 & V1 Contracts & +\texttt{hl run} for free-form trading strategies. TEE-work scripts +(\texttt{run\_agent.py}, \texttt{run\_relay.py}) for cooperative market making. +No \texttt{hl jobs} command group yet. Keeper services run as ad-hoc cron jobs. \\ +\rowcolor{info!10} +Phase 2 & L1 Testnet & +\texttt{hl jobs run oracle\_updater} and \texttt{hl jobs run liq\_flagger} +available as permissionless keeper jobs using \texttt{LogPollingSubscriber} +against V1 contracts on HyperEVM. \texttt{hl jobs run market\_maker} wraps +the tee-work \texttt{AgentClient} via \texttt{CooperativeEngine}. +\texttt{MockJobRegistryClient} used for registration and rewards (no on-chain +contract yet). \\ +\rowcolor{success!10} +Phase 3 & L1 Mainnet & +All 8 jobs available via \texttt{hl jobs run}. \texttt{ChainEventSubscriber} +replaces \texttt{LogPollingSubscriber} for real-time event delivery. +\texttt{Web3JobRegistryClient} connects to the deployed \texttt{JobRegistry} +contract for on-chain registration, staking, heartbeats, and reward claims. +Full custody enforcement active on both CLI and contract layers. \\ +\rowcolor{cooperative!8} +Phase 4 & Full Integration & +Agent Passports integrated into the registration flow. Dynamic job discovery +from the on-chain registry---the CLI queries the contract for available jobs +rather than relying solely on the local \texttt{JOB\_REGISTRY}. Passport-based +reputation scoring influences job assignment priority and reward multipliers. \\ +\bottomrule +\end{tabularx} +\caption{Phased rollout of CLI job integration, aligned with infrastructure milestones.} +\end{table} + +\begin{successbox} +The CLI job integration is considered complete when: +\begin{itemize}[leftmargin=2em] + \item All 8 jobs can be started, monitored, and stopped via \texttt{hl jobs run}. + \item The \texttt{JobEngineFactory} correctly routes each job category to its engine. + \item Custody enforcement rejects transactions outside the job's policy at the CLI layer. + \item Heartbeats are submitted automatically and visible via \texttt{hl jobs status}. + \item Rewards can be claimed via \texttt{hl jobs claim} against the live \texttt{JobRegistry} contract. + \item A new job type can be added by writing a strategy implementation and a registry entry, with no changes to the engine or routing infrastructure. +\end{itemize} +\end{successbox} + +\end{document} From 9f996319a51d13243c769ce855c9737f1ad78340 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Mon, 22 Jun 2026 13:38:30 -0400 Subject: [PATCH 3/5] Add OpenRouter support for hosted agents Use OpenRouter as the hosted Railway inference default and make the LLM strategy route OpenRouter models through the OpenAI-compatible API. Co-authored-by: Cursor --- deploy/openclaw-railway/src/bootstrap.mjs | 6 +- pyproject.toml | 2 +- strategies/claude_agent.py | 70 ++++++++++++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/deploy/openclaw-railway/src/bootstrap.mjs b/deploy/openclaw-railway/src/bootstrap.mjs index 62e101c..93e8ed0 100644 --- a/deploy/openclaw-railway/src/bootstrap.mjs +++ b/deploy/openclaw-railway/src/bootstrap.mjs @@ -73,9 +73,10 @@ export async function bootstrap() { } function buildConfig() { - const aiProvider = (process.env.AI_PROVIDER || "anthropic").toLowerCase(); - const aiKey = process.env.AI_API_KEY || ""; + const aiProvider = (process.env.AI_PROVIDER || "openrouter").toLowerCase(); + const aiKey = process.env.OPENROUTER_API_KEY || process.env.AI_API_KEY || ""; const providerInfo = PROVIDER_MAP[aiProvider] || PROVIDER_MAP.anthropic; + const model = process.env.OPENCLAW_MODEL || process.env.AI_MODEL || (aiProvider === "openrouter" ? "openrouter/auto" : ""); // For blockrun/ClawRouter: use wallet key instead of API key. // x402 protocol — payment IS authentication, no API key needed. @@ -96,6 +97,7 @@ function buildConfig() { // AI provider provider: providerInfo.provider, [providerInfo.key]: credentialValue, + ...(model ? { model } : {}), // MCP servers — our trading CLI is the primary tool provider mcpServers: { diff --git a/pyproject.toml b/pyproject.toml index ae08f9f..43e4b43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ ] [project.optional-dependencies] -llm = ["anthropic>=0.40.0"] +llm = ["anthropic>=0.40.0", "openai>=1.0.0", "google-genai>=1.0.0"] mcp = ["mcp>=1.0.0"] telegram = ["python-telegram-bot>=21.0"] dev = ["pytest>=7.0", "ruff>=0.4.0", "mypy>=1.8.0"] diff --git a/strategies/claude_agent.py b/strategies/claude_agent.py index cb4a8ff..ade3e06 100644 --- a/strategies/claude_agent.py +++ b/strategies/claude_agent.py @@ -1,4 +1,4 @@ -"""LLM-powered trading agent — supports Claude, Gemini, OpenAI, and ClawRouter. +"""LLM-powered trading agent — supports Claude, Gemini, OpenAI, OpenRouter, and ClawRouter. Uses structured tool/function calling to make trading decisions each tick. The LLM receives market data, position state, and risk context, then decides @@ -17,6 +17,9 @@ # ClawRouter (x402 — pay with USDC, no API key needed) hl run claude_agent -i ETH-PERP --tick 15 --model blockrun/auto + + # OpenRouter (Nunchi hosted default) + hl run claude_agent -i ETH-PERP --tick 15 --model openrouter/auto """ from __future__ import annotations @@ -108,6 +111,8 @@ def _detect_provider(model: str) -> str: """Detect LLM provider from model name.""" if model.startswith("blockrun"): return "blockrun" + if model.startswith("openrouter/") or os.environ.get("AI_PROVIDER", "").lower() == "openrouter": + return "openrouter" if model.startswith("gemini"): return "gemini" if model.startswith("claude"): @@ -124,7 +129,7 @@ def _detect_provider(model: str) -> str: class ClaudeStrategy(BaseStrategy): - """LLM-powered trading strategy — supports Claude and Gemini backends.""" + """LLM-powered trading strategy with multiple hosted/local inference backends.""" def __init__( self, @@ -157,6 +162,7 @@ def __init__( self._anthropic_client = None self._gemini_client = None self._openai_client = None + self._openrouter_client = None self._blockrun_client = None # ------------------------------------------------------------------ @@ -207,6 +213,27 @@ def _get_openai_client(self): self._openai_client = openai.OpenAI(api_key=api_key) return self._openai_client + def _get_openrouter_client(self): + if self._openrouter_client is None: + try: + import openai + except ImportError: + raise ImportError( + "openai package required for OpenRouter. Install: pip3 install openai" + ) + api_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("AI_API_KEY") + if not api_key: + raise ValueError("OPENROUTER_API_KEY or AI_API_KEY environment variable required") + self._openrouter_client = openai.OpenAI( + api_key=api_key, + base_url="https://openrouter.ai/api/v1", + default_headers={ + "HTTP-Referer": os.environ.get("OPENROUTER_HTTP_REFERER", "https://agent.nunchi.trade"), + "X-Title": os.environ.get("OPENROUTER_APP_TITLE", "Nunchi Hosted Agent"), + }, + ) + return self._openrouter_client + # ------------------------------------------------------------------ # Build prompt # ------------------------------------------------------------------ @@ -441,6 +468,43 @@ def _call_openai(self, user_msg: str, snapshot: MarketSnapshot) -> List[Strategy decisions.extend(self._parse_tool_call(tc.function.name, args, snapshot)) return decisions + def _call_openrouter(self, user_msg: str, snapshot: MarketSnapshot) -> List[StrategyDecision]: + import json as _json + + client = self._get_openrouter_client() + t0 = time.time() + + response = client.chat.completions.create( + model=self.model, + max_tokens=self.max_tokens, + messages=[ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": user_msg}, + ], + tools=self._build_openai_tools(), + tool_choice="required", + ) + + elapsed_ms = (time.time() - t0) * 1000 + self._api_calls += 1 + usage = response.usage + if usage: + self._total_input_tokens += usage.prompt_tokens or 0 + self._total_output_tokens += usage.completion_tokens or 0 + log.info( + "OpenRouter: %dms, %d/%d tokens (total: %d calls, %d/%d tokens)", + elapsed_ms, usage.prompt_tokens or 0, usage.completion_tokens or 0, + self._api_calls, self._total_input_tokens, self._total_output_tokens, + ) + + decisions = [] + msg = response.choices[0].message + if msg.tool_calls: + for tc in msg.tool_calls: + args = _json.loads(tc.function.arguments) if tc.function.arguments else {} + decisions.extend(self._parse_tool_call(tc.function.name, args, snapshot)) + return decisions + # ------------------------------------------------------------------ # ClawRouter / BlockRun backend (x402 — pay with USDC, no API key) # ------------------------------------------------------------------ @@ -576,6 +640,8 @@ def on_tick( provider = _detect_provider(self.model) if provider == "blockrun": decisions = self._call_blockrun(user_msg, snapshot) + elif provider == "openrouter": + decisions = self._call_openrouter(user_msg, snapshot) elif provider == "gemini": decisions = self._call_gemini(user_msg, snapshot) elif provider == "claude": From 484616a340f5d2fea5c35a6844940c1b6370d939 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Fri, 26 Jun 2026 11:13:11 -0400 Subject: [PATCH 4/5] Add BTCSWP funding hedge CLI and MCP tools Co-authored-by: Cursor --- README.md | 24 +- cli/commands/hedge.py | 86 +++++++ cli/commands/setup.py | 17 ++ cli/main.py | 2 + cli/mcp_server.py | 86 ++++++- cli/skill.md | 2 +- cli/strategy_registry.py | 2 +- modules/funding_hedge.py | 357 ++++++++++++++++++++++++++++++ strategies/hedge_agent.py | 7 +- tests/test_funding_hedge.py | 197 +++++++++++++++++ tests/test_setup_auth_guidance.py | 70 ++++++ 11 files changed, 841 insertions(+), 9 deletions(-) create mode 100644 cli/commands/hedge.py create mode 100644 modules/funding_hedge.py create mode 100644 tests/test_funding_hedge.py create mode 100644 tests/test_setup_auth_guidance.py diff --git a/README.md b/README.md index f69ebcc..f4466e4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Strategies Tests License - MCP + MCP

@@ -75,6 +75,20 @@ hl run engine_mm -i ETH-PERP --tick 10 --mainnet hl apex run --mainnet ``` +### Funding Hedge + +Propose a read-only BTCSWP funding-rate hedge from the CLI or any MCP client. This is the public, pure-math slice of the Nunchi funding hedge: same-side BTCSWP, 1/15 notional by default, no order execution. + +```bash +hl hedge propose --asset BTC --side long --perp-notional 150000 --funding-apr 42 +hl hedge propose --asset BTC --side long --perp-notional 150000 --funding-rate-8h 0.0003 --json +hl hedge backtest --csv funding.csv --asset BTC --side long --perp-notional 150000 +``` + +Backtest CSVs need a `funding_rate_8h`, `perp_funding_rate_8h`, `funding_rate`, or `rate` column. Add `hedge_rate_8h`, `btcswp_rate_8h`, or `btcswp_funding_rate_8h` when you have realized BTCSWP rates; otherwise the backtest uses an idealized offset. + +MCP tools: `funding_hedge_propose`, `funding_hedge_backtest` + --- ## Strategies @@ -119,7 +133,7 @@ Supporting strategies for portfolio management, block liquidity, and autonomous | Strategy | Description | Key Parameters | When to Use | |----------|-------------|----------------|-------------| -| `hedge_agent` | Reduces excess exposure per deterministic mandate. Fires when net notional exceeds threshold. | `notional_threshold` | Always-on risk overlay. Pairs with any MM or signal strategy. | +| `hedge_agent` | Inventory exposure reducer. Fires when net notional exceeds threshold. This is not the BTCSWP funding-rate hedge; use `hl hedge propose` / `hl hedge backtest` for that. | `notional_threshold` | Always-on risk overlay. Pairs with any MM or signal strategy. | | `rfq_agent` | Block-size dark RFQ liquidity — quotes for large orders with wider spreads. | `min_size`, `spread_bps` | Institutional/block flow. Provides hidden liquidity for large counterparties. | | `claude_agent` | Multi-model LLM trading agent. Sends market snapshot to an LLM (Gemini, Claude, or OpenAI), receives structured trade decisions. | `model`, `base_size` | Experimental/research. Autonomous decision-making using LLM reasoning. | @@ -460,6 +474,8 @@ hl radar run [options] # Opportunity radar hl pulse run [options] # Pulse momentum detector hl guard run -i ETH-PERP [options] # Guard trailing stop hl reflect run [--since DATE] # Performance review +hl hedge propose [options] # BTCSWP funding hedge proposal +hl hedge backtest --csv # Local funding hedge cashflow backtest # Infrastructure hl builder approve [--mainnet] # Approve builder fee @@ -481,7 +497,7 @@ hl mcp serve # stdio transport (default) hl mcp serve --transport sse # SSE transport ``` -**16 tools exposed:** `account`, `status`, `trade`, `run_strategy`, `strategies`, `radar_run`, `apex_status`, `apex_run`, `reflect_run`, `setup_check`, `builder_status`, `wallet_list`, `wallet_auto`, `agent_memory`, `trade_journal`, `judge_report` +**18 tools exposed:** `account`, `status`, `trade`, `run_strategy`, `strategies`, `funding_hedge_propose`, `funding_hedge_backtest`, `radar_run`, `apex_status`, `apex_run`, `reflect_run`, `setup_check`, `builder_status`, `wallet_list`, `wallet_auto`, `agent_memory`, `trade_journal`, `judge_report` Fast tools (strategies, builder, wallet, setup, memory, journal, judge) call Python directly — zero subprocess overhead. @@ -572,7 +588,7 @@ hl run engine_mm -i BTCSWP-USDYP --tick 10 ``` cli/ CLI commands and trading engine commands/ Subcommand modules (run, apex, radar, pulse, guard, reflect, house, ...) - mcp_server.py MCP server (16 tools via FastMCP) + mcp_server.py MCP server (18 tools via FastMCP) hl_adapter.py Direct HL API adapter (live + mock) builder_fee.py Builder fee config (HL native BuilderInfo) keystore.py Encrypted keystore (geth-compatible) diff --git a/cli/commands/hedge.py b/cli/commands/hedge.py new file mode 100644 index 0000000..db4cb7c --- /dev/null +++ b/cli/commands/hedge.py @@ -0,0 +1,86 @@ +"""hl hedge — funding-rate hedge proposal tools.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +import typer + +hedge_app = typer.Typer(no_args_is_help=True) + + +@hedge_app.command("propose", help="Propose a BTCSWP funding-rate hedge") +def hedge_propose( + asset: str = typer.Option("BTC", "--asset", help="Underlying perp exposure. BTC is deployed today."), + side: str = typer.Option("long", "--side", help="Perp exposure side: long or short."), + perp_notional: float = typer.Option(..., "--perp-notional", help="Absolute perp notional in USD."), + funding_apr: Optional[float] = typer.Option( + None, + "--funding-apr", + help="Annualized funding APR. Accepts 0.42 or 42 for 42%.", + ), + funding_rate_8h: Optional[float] = typer.Option( + None, + "--funding-rate-8h", + help="8h funding rate as a decimal, e.g. 0.0003. Used only if --funding-apr is omitted.", + ), + vol_multiplier: float = typer.Option(15.0, "--vol-multiplier", help="BTCSWP hedge multiplier."), + json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON."), +) -> None: + """Return a read-only BTCSWP sizing proposal for a BTC funding exposure.""" + from modules.funding_hedge import format_proposal, propose_funding_hedge + + try: + proposal = propose_funding_hedge( + asset=asset, + perp_side=side, + perp_notional_usd=perp_notional, + funding_apr=funding_apr, + funding_rate_8h=funding_rate_8h, + vol_multiplier=vol_multiplier, + ) + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + + if json_output: + typer.echo(json.dumps(proposal.to_dict(), indent=2)) + else: + typer.echo(format_proposal(proposal)) + + +@hedge_app.command("backtest", help="Backtest BTCSWP funding hedge cashflows from CSV") +def hedge_backtest( + csv_path: Path = typer.Option( + ..., + "--csv", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="CSV with funding_rate_8h/funding_rate column and optional hedge_rate_8h.", + ), + asset: str = typer.Option("BTC", "--asset", help="Underlying perp exposure. BTC is deployed today."), + side: str = typer.Option("long", "--side", help="Perp exposure side: long or short."), + perp_notional: float = typer.Option(..., "--perp-notional", help="Absolute perp notional in USD."), + vol_multiplier: float = typer.Option(15.0, "--vol-multiplier", help="BTCSWP hedge multiplier."), + json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON."), +) -> None: + """Backtest funding cashflows for a same-side BTCSWP hedge.""" + from modules.funding_hedge import backtest_funding_hedge_csv, format_backtest + + try: + backtest = backtest_funding_hedge_csv( + csv_path=csv_path, + asset=asset, + perp_side=side, + perp_notional_usd=perp_notional, + vol_multiplier=vol_multiplier, + ) + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + + if json_output: + typer.echo(json.dumps(backtest.to_dict(), indent=2)) + else: + typer.echo(format_backtest(backtest)) diff --git a/cli/commands/setup.py b/cli/commands/setup.py index 5c10152..3a9717c 100644 --- a/cli/commands/setup.py +++ b/cli/commands/setup.py @@ -19,6 +19,7 @@ def setup_check(): issues = [] ok_items = [] + warnings = [] # 1. Python + hyperliquid SDK try: @@ -30,9 +31,16 @@ def setup_check(): # 2. Private key has_env_key = bool(os.environ.get("HL_PRIVATE_KEY")) from cli.keystore import list_keystores + from cli.web_auth import get_stored_pairing has_keystore = len(list_keystores()) > 0 + pairing = get_stored_pairing() if has_env_key: ok_items.append("HL_PRIVATE_KEY set") + if pairing is None: + warnings.append( + "Raw-key mode active. For MCP/agent use, prefer `hl pair connect` or hosted Nunchi Auth " + "so the AI client receives scoped access instead of a private key." + ) elif has_keystore: ok_items.append(f"Keystore found ({len(list_keystores())} keys)") from cli.keystore import _load_env_password @@ -44,6 +52,10 @@ def setup_check(): issues.append("HL_KEYSTORE_PASSWORD not set (needed for auto-unlock)") else: issues.append("No private key: set HL_PRIVATE_KEY or run 'hl wallet import'") + if pairing is not None: + ok_items.append(f"Paired wallet active ({pairing.selected_or_master_address})") + else: + warnings.append("No paired wallet found. Run `hl pair connect` to enable browser-approved signing.") # 3. Network testnet = os.environ.get("HL_TESTNET", "true").lower() @@ -86,6 +98,11 @@ def setup_check(): else: typer.echo("\nAll checks passed.") + if warnings: + typer.echo("") + for warning in warnings: + typer.echo(f" WARN {warning}") + @setup_app.command("bootstrap") def setup_bootstrap(): diff --git a/cli/main.py b/cli/main.py index 78d7220..a6afc37 100644 --- a/cli/main.py +++ b/cli/main.py @@ -37,6 +37,7 @@ from cli.commands.keys import keys_app from cli.commands.telegram_cmd import telegram_app from cli.jobs.commands import jobs_app +from cli.commands.hedge import hedge_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) @@ -57,6 +58,7 @@ app.add_typer(keys_app, name="keys", help="Unified key management across backends") app.add_typer(telegram_app, name="telegram", help="Telegram bot — deploy agents from chat") app.add_typer(jobs_app, name="jobs", help="Perpetual agent jobs — register, run, and manage on-chain jobs") +app.add_typer(hedge_app, name="hedge", help="Funding-rate hedge proposals") def main(): diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 1544778..e80c212 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -25,7 +25,10 @@ def create_mcp_server(): """Create and configure the FastMCP server.""" from mcp.server.fastmcp import FastMCP - mcp = FastMCP("yex-trader", instructions="Autonomous Hyperliquid trading CLI — 14 strategies, APEX orchestrator, REFLECT reviews.") + mcp = FastMCP( + "yex-trader", + instructions="Autonomous Hyperliquid trading CLI — 14 strategies, APEX orchestrator, REFLECT reviews, BTCSWP funding hedge proposals.", + ) # ------------------------------------------------------------------ # Fast tools — call Python directly (no subprocess overhead) @@ -113,6 +116,7 @@ def setup_check() -> str: issues = [] ok_items = [] + warnings = [] # SDK try: @@ -124,12 +128,22 @@ def setup_check() -> str: # Key has_env_key = bool(os.environ.get("HL_PRIVATE_KEY")) keystores = list_keystores() + from cli.web_auth import get_stored_pairing + pairing = get_stored_pairing() if has_env_key: ok_items.append("HL_PRIVATE_KEY set") + if pairing is None: + warnings.append( + "Raw-key mode active. Prefer hl pair connect or hosted Nunchi Auth for MCP/agent use." + ) elif keystores: ok_items.append(f"Keystore found ({len(keystores)} keys)") else: issues.append("No private key: set HL_PRIVATE_KEY or run wallet_auto") + if pairing is not None: + ok_items.append(f"Paired wallet active ({pairing.selected_or_master_address})") + else: + warnings.append("No paired wallet found. Run hl pair connect to enable browser-approved signing.") # Network testnet = os.environ.get("HL_TESTNET", "true").lower() @@ -145,10 +159,80 @@ def setup_check() -> str: return json.dumps({ "ok": ok_items, + "warnings": warnings, "issues": issues, "passed": len(issues) == 0, }, indent=2) + @mcp.tool() + def funding_hedge_propose( + asset: str = "BTC", + perp_side: str = "long", + perp_notional_usd: float = 100_000.0, + funding_apr: Optional[float] = None, + funding_rate_8h: Optional[float] = None, + vol_multiplier: float = 15.0, + ) -> str: + """Propose a read-only BTCSWP funding-rate hedge. + + Args: + asset: Underlying perp exposure. BTC is deployed today. + perp_side: Perp exposure side — "long" or "short". + perp_notional_usd: Absolute perp notional in USD. + funding_apr: Annualized funding APR. Accepts 0.42 or 42 for 42%. + funding_rate_8h: 8h funding rate as a decimal, used if funding_apr is omitted. + vol_multiplier: BTCSWP hedge multiplier. Default 15 means 1/15 notional. + """ + from modules.funding_hedge import propose_funding_hedge + + try: + proposal = propose_funding_hedge( + asset=asset, + perp_side=perp_side, + perp_notional_usd=perp_notional_usd, + funding_apr=funding_apr, + funding_rate_8h=funding_rate_8h, + vol_multiplier=vol_multiplier, + ) + except ValueError as exc: + return json.dumps({"error": str(exc)}, indent=2) + return json.dumps(proposal.to_dict(), indent=2) + + @mcp.tool() + def funding_hedge_backtest( + csv_path: str, + asset: str = "BTC", + perp_side: str = "long", + perp_notional_usd: float = 100_000.0, + vol_multiplier: float = 15.0, + ) -> str: + """Backtest BTCSWP funding hedge cashflows from a local CSV. + + The CSV must include funding_rate_8h, perp_funding_rate_8h, funding_rate, + or rate. It may also include hedge_rate_8h, btcswp_rate_8h, or + btcswp_funding_rate_8h for realized hedge residuals. + + Args: + csv_path: Local CSV path readable by the MCP server process. + asset: Underlying perp exposure. BTC is deployed today. + perp_side: Perp exposure side — "long" or "short". + perp_notional_usd: Absolute perp notional in USD. + vol_multiplier: BTCSWP hedge multiplier. Default 15 means 1/15 notional. + """ + from modules.funding_hedge import backtest_funding_hedge_csv + + try: + backtest = backtest_funding_hedge_csv( + csv_path=csv_path, + asset=asset, + perp_side=perp_side, + perp_notional_usd=perp_notional_usd, + vol_multiplier=vol_multiplier, + ) + except (OSError, ValueError) as exc: + return json.dumps({"error": str(exc)}, indent=2) + return json.dumps(backtest.to_dict(), indent=2) + @mcp.tool() def account(mainnet: bool = False) -> str: """Get Hyperliquid account state (balances, positions).""" diff --git a/cli/skill.md b/cli/skill.md index 9cd0388..0b7b574 100644 --- a/cli/skill.md +++ b/cli/skill.md @@ -203,7 +203,7 @@ Tools: `strategies`, `builder_status`, `wallet_list`, `wallet_auto`, `setup_chec | mean_reversion | Signal | Trades when price deviates from SMA | | momentum_breakout | Signal | Enters on volume + price breakout above/below N-period range | | aggressive_taker | Taker | Directional spread crossing with bias | -| hedge_agent | Risk | Reduces excess exposure per deterministic mandate | +| hedge_agent | Risk | Inventory exposure reducer; BTCSWP funding hedge lives under `hl hedge` | | rfq_agent | RFQ | Block-size dark RFQ liquidity | | claude_agent | LLM | Claude/Gemini-powered autonomous trading agent | diff --git a/cli/strategy_registry.py b/cli/strategy_registry.py index 4bcfb90..0ea02e3 100644 --- a/cli/strategy_registry.py +++ b/cli/strategy_registry.py @@ -21,7 +21,7 @@ }, "hedge_agent": { "path": "strategies.hedge_agent:HedgeAgent", - "description": "Reduces excess exposure per deterministic mandate", + "description": "Inventory exposure reducer; not the BTCSWP funding-rate hedge", "params": {"notional_threshold": 15000.0}, }, "rfq_agent": { diff --git a/modules/funding_hedge.py b/modules/funding_hedge.py new file mode 100644 index 0000000..93feff1 --- /dev/null +++ b/modules/funding_hedge.py @@ -0,0 +1,357 @@ +"""Pure-math funding-rate hedge proposal helpers. + +This module intentionally does not talk to Hyperliquid or sign orders. It gives +agents a deterministic way to size the public BTCSWP hedge slice. +""" +from __future__ import annotations + +import csv +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable, Literal, Optional + + +Side = Literal["long", "short"] + +BTCSWP_PROFILE = { + "asset": "BTC", + "hedge_market": "BTCSWP-USDYP", + "hl_coin": "yex:BTCSWP", + "vol_multiplier": 15.0, + "status": "deployed", +} + + +@dataclass(frozen=True) +class FundingHedgeProposal: + asset: str + perp_side: Side + perp_notional_usd: float + funding_apr: float + funding_rate_8h: Optional[float] + hedge_market: str + hedge_hl_coin: str + hedge_side: Side + hedge_notional_usd: float + vol_multiplier: float + effective_hedged_notional_usd: float + coverage_pct: float + unhedged_funding_cashflow_usd_per_year: float + target_hedge_cashflow_usd_per_year: float + assumption: str + status: str + disclaimer: str + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +@dataclass(frozen=True) +class FundingHedgeBacktestRow: + index: int + timestamp: Optional[str] + funding_rate_8h: float + hedge_rate_8h: float + unhedged_cashflow_usd: float + hedge_cashflow_usd: float + net_cashflow_usd: float + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +@dataclass(frozen=True) +class FundingHedgeBacktest: + asset: str + perp_side: Side + perp_notional_usd: float + hedge_market: str + hedge_hl_coin: str + hedge_side: Side + hedge_notional_usd: float + vol_multiplier: float + effective_hedged_notional_usd: float + coverage_pct: float + periods: int + average_funding_rate_8h: float + annualized_average_funding_apr: float + unhedged_cashflow_usd: float + hedge_cashflow_usd: float + net_cashflow_usd: float + max_period_unhedged_payment_usd: float + max_period_net_cost_usd: float + rows: list[FundingHedgeBacktestRow] + assumption: str + disclaimer: str + + def to_dict(self) -> dict[str, object]: + payload = asdict(self) + payload["rows"] = [row.to_dict() for row in self.rows] + return payload + + +def normalize_side(side: str) -> Side: + normalized = side.strip().lower() + if normalized not in {"long", "short"}: + raise ValueError("side must be 'long' or 'short'") + return normalized # type: ignore[return-value] + + +def normalize_apr(value: float) -> float: + """Accept either decimal APR (0.42) or percent APR (42).""" + if abs(value) > 1: + return value / 100.0 + return value + + +def annualize_funding_rate_8h(rate: float) -> float: + """Convert an 8h funding rate into simple annualized APR.""" + return rate * 3 * 365 + + +def _normalize_rate(value: float) -> float: + """Accept decimals, or whole percent values when clearly percent-like.""" + if abs(value) > 1: + return value / 100.0 + return value + + +def propose_funding_hedge( + *, + asset: str = "BTC", + perp_side: str = "long", + perp_notional_usd: float, + funding_apr: Optional[float] = None, + funding_rate_8h: Optional[float] = None, + vol_multiplier: float = BTCSWP_PROFILE["vol_multiplier"], +) -> FundingHedgeProposal: + """Size a BTCSWP hedge for a BTC perp funding exposure. + + Positive funding means longs pay shorts. The BTCSWP hedge is same-side and + sized at 1 / vol_multiplier notional so the rate leg targets the full perp + notional. + """ + asset = asset.strip().upper() + if asset != "BTC": + raise ValueError("only BTC funding hedges are deployed today; ETH/HYPE/SPCX profiles are roadmap") + if perp_notional_usd <= 0: + raise ValueError("perp_notional_usd must be positive") + if vol_multiplier <= 0: + raise ValueError("vol_multiplier must be positive") + if funding_apr is None and funding_rate_8h is None: + raise ValueError("provide funding_apr or funding_rate_8h") + + side = normalize_side(perp_side) + apr = annualize_funding_rate_8h(funding_rate_8h) if funding_apr is None else normalize_apr(funding_apr) + side_sign = 1 if side == "long" else -1 + + hedge_notional = perp_notional_usd / vol_multiplier + effective_notional = hedge_notional * vol_multiplier + unhedged_cashflow = -side_sign * perp_notional_usd * apr + target_hedge_cashflow = -unhedged_cashflow + + return FundingHedgeProposal( + asset=asset, + perp_side=side, + perp_notional_usd=round(perp_notional_usd, 2), + funding_apr=apr, + funding_rate_8h=funding_rate_8h, + hedge_market=BTCSWP_PROFILE["hedge_market"], + hedge_hl_coin=BTCSWP_PROFILE["hl_coin"], + hedge_side=side, + hedge_notional_usd=round(hedge_notional, 2), + vol_multiplier=vol_multiplier, + effective_hedged_notional_usd=round(effective_notional, 2), + coverage_pct=round(effective_notional / perp_notional_usd * 100, 4), + unhedged_funding_cashflow_usd_per_year=round(unhedged_cashflow, 2), + target_hedge_cashflow_usd_per_year=round(target_hedge_cashflow, 2), + assumption=( + "BTCSWP hedge is same-side and uses 1/15 notional by default; " + "positive funding means long perps pay shorts." + ), + status=BTCSWP_PROFILE["status"], + disclaimer="Sizing proposal only. This command does not place orders or expose the private rate methodology.", + ) + + +def _first_present(row: dict[str, str], names: Iterable[str]) -> Optional[str]: + for name in names: + value = row.get(name) + if value not in (None, ""): + return value + return None + + +def load_funding_rows_from_csv(path: str | Path) -> list[dict[str, Optional[str] | float]]: + """Load funding rows from CSV. + + Required column aliases: funding_rate_8h, perp_funding_rate_8h, funding_rate, or rate. + Optional hedge aliases: hedge_rate_8h, btcswp_rate_8h, btcswp_funding_rate_8h. + """ + csv_path = Path(path) + rows: list[dict[str, Optional[str] | float]] = [] + with csv_path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + for index, raw in enumerate(reader, start=1): + normalized = {(key or "").strip().lower(): (value or "").strip() for key, value in raw.items()} + funding_raw = _first_present( + normalized, + ("funding_rate_8h", "perp_funding_rate_8h", "funding_rate", "rate"), + ) + if funding_raw is None: + raise ValueError( + "CSV must include funding_rate_8h, perp_funding_rate_8h, funding_rate, or rate" + ) + hedge_raw = _first_present( + normalized, + ("hedge_rate_8h", "btcswp_rate_8h", "btcswp_funding_rate_8h"), + ) + try: + funding_rate = _normalize_rate(float(funding_raw)) + hedge_rate = _normalize_rate(float(hedge_raw)) if hedge_raw is not None else funding_rate + except ValueError as exc: + raise ValueError(f"invalid funding rate on CSV row {index}") from exc + rows.append( + { + "timestamp": _first_present(normalized, ("timestamp", "time", "date")), + "funding_rate_8h": funding_rate, + "hedge_rate_8h": hedge_rate, + } + ) + if not rows: + raise ValueError("CSV contains no funding rows") + return rows + + +def backtest_funding_hedge( + *, + funding_rows: list[dict[str, Optional[str] | float]], + asset: str = "BTC", + perp_side: str = "long", + perp_notional_usd: float, + vol_multiplier: float = BTCSWP_PROFILE["vol_multiplier"], +) -> FundingHedgeBacktest: + """Backtest funding cashflows for a same-side BTCSWP hedge.""" + proposal = propose_funding_hedge( + asset=asset, + perp_side=perp_side, + perp_notional_usd=perp_notional_usd, + funding_rate_8h=float(funding_rows[0]["funding_rate_8h"]), + vol_multiplier=vol_multiplier, + ) + side_sign = 1 if proposal.perp_side == "long" else -1 + + detail_rows: list[FundingHedgeBacktestRow] = [] + for index, row in enumerate(funding_rows, start=1): + funding_rate = float(row["funding_rate_8h"]) + hedge_rate = float(row["hedge_rate_8h"]) + unhedged = -side_sign * perp_notional_usd * funding_rate + hedge = side_sign * proposal.effective_hedged_notional_usd * hedge_rate + net = unhedged + hedge + detail_rows.append( + FundingHedgeBacktestRow( + index=index, + timestamp=str(row["timestamp"]) if row.get("timestamp") else None, + funding_rate_8h=funding_rate, + hedge_rate_8h=hedge_rate, + unhedged_cashflow_usd=round(unhedged, 2), + hedge_cashflow_usd=round(hedge, 2), + net_cashflow_usd=round(net, 2), + ) + ) + + periods = len(detail_rows) + avg_rate = sum(row.funding_rate_8h for row in detail_rows) / periods + unhedged_total = sum(row.unhedged_cashflow_usd for row in detail_rows) + hedge_total = sum(row.hedge_cashflow_usd for row in detail_rows) + net_total = sum(row.net_cashflow_usd for row in detail_rows) + max_unhedged_payment = max(max(-row.unhedged_cashflow_usd, 0.0) for row in detail_rows) + max_net_cost = max(max(-row.net_cashflow_usd, 0.0) for row in detail_rows) + + return FundingHedgeBacktest( + asset=proposal.asset, + perp_side=proposal.perp_side, + perp_notional_usd=proposal.perp_notional_usd, + hedge_market=proposal.hedge_market, + hedge_hl_coin=proposal.hedge_hl_coin, + hedge_side=proposal.hedge_side, + hedge_notional_usd=proposal.hedge_notional_usd, + vol_multiplier=proposal.vol_multiplier, + effective_hedged_notional_usd=proposal.effective_hedged_notional_usd, + coverage_pct=proposal.coverage_pct, + periods=periods, + average_funding_rate_8h=round(avg_rate, 10), + annualized_average_funding_apr=round(annualize_funding_rate_8h(avg_rate), 6), + unhedged_cashflow_usd=round(unhedged_total, 2), + hedge_cashflow_usd=round(hedge_total, 2), + net_cashflow_usd=round(net_total, 2), + max_period_unhedged_payment_usd=round(max_unhedged_payment, 2), + max_period_net_cost_usd=round(max_net_cost, 2), + rows=detail_rows, + assumption=( + "If no hedge_rate_8h/BTCSWP column is supplied, the backtest assumes " + "the BTCSWP hedge rate equals the perp funding rate for an idealized offset." + ), + disclaimer="Backtest is local cashflow math only. It does not place orders or model liquidity, fees, or mark-to-market.", + ) + + +def backtest_funding_hedge_csv( + *, + csv_path: str | Path, + asset: str = "BTC", + perp_side: str = "long", + perp_notional_usd: float, + vol_multiplier: float = BTCSWP_PROFILE["vol_multiplier"], +) -> FundingHedgeBacktest: + return backtest_funding_hedge( + funding_rows=load_funding_rows_from_csv(csv_path), + asset=asset, + perp_side=perp_side, + perp_notional_usd=perp_notional_usd, + vol_multiplier=vol_multiplier, + ) + + +def format_proposal(proposal: FundingHedgeProposal) -> str: + direction = "paying" if proposal.unhedged_funding_cashflow_usd_per_year < 0 else "receiving" + return "\n".join( + [ + "Funding Hedge Proposal", + "=" * 40, + f"Exposure: {proposal.perp_side.upper()} {proposal.asset} perp ${proposal.perp_notional_usd:,.2f}", + f"Funding APR: {proposal.funding_apr * 100:,.2f}%", + f"Unhedged leg: {direction} ${abs(proposal.unhedged_funding_cashflow_usd_per_year):,.2f}/yr", + "", + f"Hedge market: {proposal.hedge_market} ({proposal.hedge_hl_coin})", + f"Hedge action: {proposal.hedge_side.upper()} ${proposal.hedge_notional_usd:,.2f}", + f"Multiplier: {proposal.vol_multiplier:,.2f}x", + f"Coverage: ${proposal.effective_hedged_notional_usd:,.2f} ({proposal.coverage_pct:.2f}%)", + f"Target offset: ${proposal.target_hedge_cashflow_usd_per_year:,.2f}/yr", + "", + f"Assumption: {proposal.assumption}", + f"Status: {proposal.status}", + f"Disclaimer: {proposal.disclaimer}", + ] + ) + + +def format_backtest(backtest: FundingHedgeBacktest) -> str: + return "\n".join( + [ + "Funding Hedge Backtest", + "=" * 40, + f"Exposure: {backtest.perp_side.upper()} {backtest.asset} perp ${backtest.perp_notional_usd:,.2f}", + f"Hedge: {backtest.hedge_side.upper()} ${backtest.hedge_notional_usd:,.2f} {backtest.hedge_market}", + f"Periods: {backtest.periods}", + f"Avg funding APR: {backtest.annualized_average_funding_apr * 100:,.2f}%", + "", + f"Unhedged cashflow:{backtest.unhedged_cashflow_usd:>15,.2f} USD", + f"Hedge cashflow: {backtest.hedge_cashflow_usd:>15,.2f} USD", + f"Net cashflow: {backtest.net_cashflow_usd:>15,.2f} USD", + f"Max net cost: {backtest.max_period_net_cost_usd:>15,.2f} USD / period", + "", + f"Assumption: {backtest.assumption}", + f"Disclaimer: {backtest.disclaimer}", + ] + ) diff --git a/strategies/hedge_agent.py b/strategies/hedge_agent.py index 5068ad4..2d13d36 100644 --- a/strategies/hedge_agent.py +++ b/strategies/hedge_agent.py @@ -1,8 +1,11 @@ -"""Hedge agent — reduces excess exposure per deterministic mandate. +"""Hedge agent — reduces inventory exposure per deterministic mandate. From KorAI spec: "reduces exposure per deterministic mandate." Only acts when |inventory| exceeds a configurable threshold, then places aggressive orders to bring inventory back toward zero. + +This is not the BTCSWP funding-rate hedge. Use `hl hedge propose` or +`hl hedge backtest` for the public funding hedge tooling. """ from __future__ import annotations @@ -13,7 +16,7 @@ class HedgeAgent(BaseStrategy): - """Deterministic hedge agent that reduces inventory when overexposed.""" + """Deterministic inventory hedge agent that reduces overexposure.""" def __init__( self, diff --git a/tests/test_funding_hedge.py b/tests/test_funding_hedge.py new file mode 100644 index 0000000..bb5e438 --- /dev/null +++ b/tests/test_funding_hedge.py @@ -0,0 +1,197 @@ +"""Tests for BTCSWP funding hedge proposal surfaces.""" +from __future__ import annotations + +import json +import sys +import types + +from typer.testing import CliRunner + +from cli.main import app +from modules.funding_hedge import annualize_funding_rate_8h, backtest_funding_hedge_csv, propose_funding_hedge + + +runner = CliRunner() + + +class FakeFastMCP: + def __init__(self, *args, **kwargs): + self.tools = {} + + def tool(self): + def decorator(fn): + self.tools[fn.__name__] = fn + return fn + + return decorator + + +def install_fake_mcp(monkeypatch) -> None: + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + +def test_propose_btcswp_funding_hedge_percent_apr(): + proposal = propose_funding_hedge( + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + funding_apr=42, + ) + + assert proposal.hedge_market == "BTCSWP-USDYP" + assert proposal.hedge_side == "long" + assert proposal.hedge_notional_usd == 10_000 + assert proposal.effective_hedged_notional_usd == 150_000 + assert proposal.funding_apr == 0.42 + assert proposal.unhedged_funding_cashflow_usd_per_year == -63_000 + assert proposal.target_hedge_cashflow_usd_per_year == 63_000 + + +def test_propose_annualizes_8h_funding_rate(): + apr = annualize_funding_rate_8h(0.0003) + proposal = propose_funding_hedge( + asset="BTC", + perp_side="short", + perp_notional_usd=90_000, + funding_rate_8h=0.0003, + ) + + assert proposal.funding_apr == apr + assert proposal.hedge_notional_usd == 6_000 + assert proposal.unhedged_funding_cashflow_usd_per_year == 29_565 + + +def test_hedge_propose_cli_json(): + result = runner.invoke( + app, + ["hedge", "propose", "--perp-notional", "150000", "--side", "long", "--funding-apr", "42", "--json"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["hedge_market"] == "BTCSWP-USDYP" + assert payload["hedge_notional_usd"] == 10_000 + assert payload["disclaimer"].startswith("Sizing proposal only.") + + +def test_mcp_funding_hedge_propose(monkeypatch): + install_fake_mcp(monkeypatch) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads( + server.tools["funding_hedge_propose"]( + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + funding_apr=42, + ) + ) + + assert payload["hedge_market"] == "BTCSWP-USDYP" + assert payload["hedge_side"] == "long" + assert payload["hedge_notional_usd"] == 10_000 + assert payload["coverage_pct"] == 100 + + +def test_mcp_funding_hedge_rejects_roadmap_assets(monkeypatch): + install_fake_mcp(monkeypatch) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads(server.tools["funding_hedge_propose"](asset="ETH", funding_apr=10)) + + assert "only BTC funding hedges are deployed today" in payload["error"] + + +def test_backtest_csv_idealized_offset(tmp_path): + csv_path = tmp_path / "funding.csv" + csv_path.write_text("timestamp,funding_rate_8h\n1,0.0003\n2,-0.0001\n", "utf-8") + + backtest = backtest_funding_hedge_csv( + csv_path=csv_path, + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + ) + + assert backtest.periods == 2 + assert backtest.hedge_notional_usd == 10_000 + assert backtest.unhedged_cashflow_usd == -30 + assert backtest.hedge_cashflow_usd == 30 + assert backtest.net_cashflow_usd == 0 + + +def test_backtest_csv_realized_hedge_residual(tmp_path): + csv_path = tmp_path / "funding.csv" + csv_path.write_text("date,funding_rate_8h,btcswp_rate_8h\n2026-01-01,0.0003,0.00025\n", "utf-8") + + backtest = backtest_funding_hedge_csv( + csv_path=csv_path, + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + ) + + assert backtest.unhedged_cashflow_usd == -45 + assert backtest.hedge_cashflow_usd == 37.5 + assert backtest.net_cashflow_usd == -7.5 + assert backtest.max_period_net_cost_usd == 7.5 + + +def test_hedge_backtest_cli_json(tmp_path): + csv_path = tmp_path / "funding.csv" + csv_path.write_text("funding_rate\n0.0003\n-0.0001\n", "utf-8") + + result = runner.invoke( + app, + [ + "hedge", + "backtest", + "--csv", + str(csv_path), + "--perp-notional", + "150000", + "--side", + "long", + "--json", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["periods"] == 2 + assert payload["hedge_market"] == "BTCSWP-USDYP" + assert payload["net_cashflow_usd"] == 0 + + +def test_mcp_funding_hedge_backtest(monkeypatch, tmp_path): + csv_path = tmp_path / "funding.csv" + csv_path.write_text("funding_rate_8h\n0.0003\n", "utf-8") + install_fake_mcp(monkeypatch) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads( + server.tools["funding_hedge_backtest"]( + csv_path=str(csv_path), + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + ) + ) + + assert payload["periods"] == 1 + assert payload["unhedged_cashflow_usd"] == -45 + assert payload["hedge_cashflow_usd"] == 45 diff --git a/tests/test_setup_auth_guidance.py b/tests/test_setup_auth_guidance.py new file mode 100644 index 0000000..c556a2f --- /dev/null +++ b/tests/test_setup_auth_guidance.py @@ -0,0 +1,70 @@ +"""Tests for setup auth-mode guidance.""" +from __future__ import annotations + +import json +import sys +import types + +from typer.testing import CliRunner + +from cli.commands.setup import setup_app + + +runner = CliRunner() + + +class FakeFastMCP: + def __init__(self, *args, **kwargs): + self.tools = {} + + def tool(self): + def decorator(fn): + self.tools[fn.__name__] = fn + return fn + + return decorator + + +def install_fake_mcp(monkeypatch) -> None: + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + +def install_setup_fakes(monkeypatch, paired_wallet=None) -> None: + monkeypatch.setitem(sys.modules, "hyperliquid", types.ModuleType("hyperliquid")) + monkeypatch.setattr("cli.keystore.list_keystores", lambda: []) + monkeypatch.setattr("cli.web_auth.get_stored_pairing", lambda: paired_wallet) + + +def test_setup_check_warns_on_raw_key_without_pairing(monkeypatch): + install_setup_fakes(monkeypatch) + monkeypatch.setenv("HL_PRIVATE_KEY", "0x" + "1" * 64) + + result = runner.invoke(setup_app, ["check"]) + + assert result.exit_code == 0 + assert "HL_PRIVATE_KEY set" in result.output + assert "Raw-key mode active" in result.output + assert "hl pair connect" in result.output + + +def test_mcp_setup_check_reports_auth_warnings(monkeypatch): + install_setup_fakes(monkeypatch) + install_fake_mcp(monkeypatch) + monkeypatch.setenv("HL_PRIVATE_KEY", "0x" + "1" * 64) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads(server.tools["setup_check"]()) + + assert "HL_PRIVATE_KEY set" in payload["ok"] + assert any("Raw-key mode active" in warning for warning in payload["warnings"]) + assert any("No paired wallet found" in warning for warning in payload["warnings"]) From a7f3cdc2872b07ef21f23c566de3064482d1b0ab Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Tue, 30 Jun 2026 14:26:16 -0400 Subject: [PATCH 5/5] Unblock hosted agent cost E2E Add safe MCP trading, joinable cost/fill ledgers, cache metrics, and web-auth maker/taker role binding so funded testnet costing can run through agent-cli. Co-authored-by: Cursor --- cli/commands/pair.py | 409 ++++++++++++++++++++ cli/commands/trade.py | 93 ++++- cli/config.py | 55 +++ cli/engine.py | 145 +++++++- cli/main.py | 2 + cli/mcp_server.py | 201 +++++++++- cli/web_auth.py | 517 ++++++++++++++++++++++++++ modules/cost_metering.py | 261 +++++++++++++ requirements.txt | 7 +- scripts/funded_btcswp_combined_run.py | 224 +++++++++++ scripts/pricing_aggregate.py | 305 +++++++++++++++ strategies/claude_agent.py | 467 ++++++++++++++++++++++- tests/test_cost_metering.py | 171 +++++++++ tests/test_engine.py | 14 + tests/test_mcp_money_tools.py | 205 ++++++++++ tests/test_pair_money_cli.py | 177 +++++++++ tests/test_strategy_claude_agent.py | 78 ++++ tests/test_trade_command.py | 102 +++++ 18 files changed, 3401 insertions(+), 32 deletions(-) create mode 100644 cli/commands/pair.py create mode 100644 cli/web_auth.py create mode 100644 modules/cost_metering.py create mode 100644 scripts/funded_btcswp_combined_run.py create mode 100644 scripts/pricing_aggregate.py create mode 100644 tests/test_cost_metering.py create mode 100644 tests/test_mcp_money_tools.py create mode 100644 tests/test_pair_money_cli.py create mode 100644 tests/test_trade_command.py diff --git a/cli/commands/pair.py b/cli/commands/pair.py new file mode 100644 index 0000000..88dabe2 --- /dev/null +++ b/cli/commands/pair.py @@ -0,0 +1,409 @@ +"""hl pair — manage the web-auth paired-wallet handshake.""" +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +import typer + +pair_app = typer.Typer(no_args_is_help=True) + + +def _ensure_path() -> None: + project_root = str(Path(__file__).resolve().parent.parent.parent) + if project_root not in sys.path: + sys.path.insert(0, project_root) + + +def _short_addr(addr: str) -> str: + return f"{addr[:6]}...{addr[-4:]}" + + +def _humanize_age(paired_at_ms: int) -> str: + age_s = max(0, int(time.time() - paired_at_ms / 1000)) + if age_s < 60: + return f"{age_s}s ago" + if age_s < 3600: + return f"{age_s // 60}m ago" + if age_s < 86400: + return f"{age_s // 3600}h ago" + return f"{age_s // 86400}d ago" + + +@pair_app.command("connect", help="Pair the CLI with web-auth via the browser") +def pair_connect( + no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening a browser."), + timeout: int = typer.Option(300, "--timeout", help="Seconds to wait for browser approval."), + app_name: str = typer.Option("HL Agent CLI", "--app-name", help="Display name shown on the authorize page."), +) -> None: + _ensure_path() + from cli.web_auth import PairingTimedOutError, get_stored_pairing, start_pairing + + existing = get_stored_pairing() + if existing: + typer.echo( + f"Already paired as {existing.label or '-'} " + f"({len(existing.addresses)} address{'es' if len(existing.addresses) != 1 else ''})." + ) + typer.echo("Run `hl pair revoke` first if you want to re-pair.", err=True) + raise typer.Exit(1) + + def _on_url(url: str) -> None: + if no_browser: + typer.echo("Open this URL in your browser to approve pairing:") + typer.echo(f" {url}") + else: + typer.echo(f"Opening browser -> {url}") + typer.echo("") + typer.echo(f"Waiting for approval (up to {timeout}s)...") + + last_tick = [time.monotonic()] + + def _on_polling() -> None: + if time.monotonic() - last_tick[0] >= 10: + typer.echo(" ...still waiting in browser...") + last_tick[0] = time.monotonic() + + try: + result = start_pairing( + app_name=app_name, + no_browser=no_browser, + on_url=_on_url, + on_polling=_on_polling, + timeout_s=timeout, + ) + except PairingTimedOutError as exc: + typer.echo(f"\n{exc}", err=True) + raise typer.Exit(1) + except KeyboardInterrupt: + typer.echo("\nCancelled.", err=True) + raise typer.Exit(1) + except Exception as exc: + typer.echo(f"\nPair failed: {exc}", err=True) + raise typer.Exit(1) + + typer.echo("") + typer.echo( + f"Paired as {result.label or '-'} - {len(result.addresses)} " + f"address{'es' if len(result.addresses) != 1 else ''}." + ) + if result.master_address: + typer.echo(f"Master: {_short_addr(result.master_address)} {result.master_address}") + for addr in result.addresses: + typer.echo(f" {_short_addr(addr)} {addr}") + typer.echo("") + typer.echo("Use `hl pair sign-test` to verify the signing relay works end-to-end.") + + +@pair_app.command("status", help="Show current pairing state") +def pair_status() -> None: + _ensure_path() + from cli.web_auth import PAIR_API_BASE, STORAGE_PATH, fetch_health, get_stored_pairing, verify_pairing + + pairing = get_stored_pairing() + health = fetch_health() + typer.echo(f"web-auth: {PAIR_API_BASE}") + typer.echo(" status: ok" if health else " status: UNREACHABLE") + typer.echo("") + + if pairing is None: + typer.echo("Pairing: NONE") + typer.echo(f" storage: {STORAGE_PATH} (missing or stale >28d)") + typer.echo(" Run `hl pair connect` to pair.") + return + + remote = None + try: + remote = verify_pairing() + except Exception as exc: + typer.echo(f" verify: {exc}", err=True) + + typer.echo("Pairing: ACTIVE") + typer.echo(f" label: {pairing.label or '-'}") + typer.echo(f" paired: {_humanize_age(pairing.paired_at_ms)}") + if pairing.account_id: + typer.echo(f" account: {pairing.account_id}") + if pairing.master_address: + typer.echo(f" master: {pairing.master_address}") + typer.echo(f" addresses ({len(pairing.addresses)}):") + for index, addr in enumerate(pairing.addresses): + marker = " *" if pairing.selected_address == addr else "" + typer.echo(f" [{index}] {addr}{marker}") + typer.echo(f" selected: {pairing.selected_or_master_address}") + if remote and remote.get("activeSession"): + typer.echo(" active session: yes") + typer.echo(f" storage: {STORAGE_PATH}") + + +@pair_app.command("list", help="List paired wallets as JSON") +def pair_list() -> None: + _ensure_path() + from cli.web_auth import get_stored_pairing + + pairing = get_stored_pairing() + if pairing is None: + typer.echo('{"ok": false, "wallets": [], "selectedAddress": null}') + return + typer.echo( + json.dumps( + { + "ok": True, + "label": pairing.label, + "accountId": pairing.account_id, + "masterAddress": pairing.master_address, + "selectedAddress": pairing.selected_or_master_address, + "wallets": [ + { + "index": index, + "address": address, + "selected": address == pairing.selected_or_master_address, + } + for index, address in enumerate(pairing.addresses) + ], + }, + indent=2, + ) + ) + + +@pair_app.command("select", help="Select which paired wallet CLI actions should use") +def pair_select(wallet: str = typer.Argument(..., help="paired wallet address or list index")) -> None: + _ensure_path() + from cli.web_auth import PairingMissingError, select_pairing_address + + try: + pairing = select_pairing_address(wallet) + except PairingMissingError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + except ValueError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(2) + typer.echo(f"Selected paired wallet: {pairing.selected_address}") + + +@pair_app.command("open", help="Open web-auth for review, approval, revocation, or wallet binding") +def pair_open( + no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening a browser."), + account_id: str = typer.Option("", "--account-id", help="Optional account id for agent-wallet binding view."), + agent_id: str = typer.Option("", "--agent-id", help="Optional agent id for agent-wallet binding view."), + agent_name: str = typer.Option("", "--agent-name", help="Optional display name for the agent-wallet binding view."), + include_pair_token: bool = typer.Option( + False, + "--include-pair-token", + help="Include the stored pair token in the web-auth URL so the UI can persist a binding for this CLI.", + ), +) -> None: + _ensure_path() + from cli.web_auth import open_wallet_ui + + url = open_wallet_ui( + no_browser=no_browser, + account_id=account_id or None, + agent_id=agent_id or None, + agent_name=agent_name or None, + include_pair_token=include_pair_token, + ) + typer.echo(f"web-auth: {url}") + + +@pair_app.command("bind-role", help="Open web-auth to select and persist a maker/taker agent wallet") +def pair_bind_role( + role: str = typer.Argument(..., help="Role to bind: maker or taker"), + account_id: str = typer.Option("", "--account-id", help="web-auth account id for the binding. Defaults to the paired account."), + agent_id: str = typer.Option("", "--agent-id", help="Override agent id. Defaults to agent-cli-cost-e2e-."), + agent_name: str = typer.Option("", "--agent-name", help="Override display name in web-auth."), + timeout: int = typer.Option(300, "--timeout", help="Seconds to wait for the web-auth selection."), + no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening a browser."), +) -> None: + _ensure_path() + from cli.web_auth import ( + PairingMissingError, + PairingTimedOutError, + open_wallet_ui, + require_pairing, + wait_for_agent_wallet_binding, + ) + + try: + pairing = require_pairing() + except PairingMissingError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + + role = role.lower().strip() + if role not in {"maker", "taker"}: + typer.echo("role must be `maker` or `taker`", err=True) + raise typer.Exit(2) + resolved_account_id = account_id or pairing.account_id or "agent-cli-cost-e2e" + resolved_agent_id = agent_id or f"agent-cli-cost-e2e-{role}" + resolved_agent_name = agent_name or f"Agent CLI Cost E2E {role.title()}" + + try: + url = open_wallet_ui( + no_browser=no_browser, + account_id=resolved_account_id, + agent_id=resolved_agent_id, + agent_name=resolved_agent_name, + include_pair_token=True, + ) + except PairingMissingError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + typer.echo(f"Open web-auth and select the {role} wallet:") + typer.echo(f" {url}") + typer.echo(f"Waiting for {role} binding to persist (up to {timeout}s)...") + + last_tick = [time.monotonic()] + + def _on_polling() -> None: + if time.monotonic() - last_tick[0] >= 10: + typer.echo(" ...still waiting for web-auth binding...") + last_tick[0] = time.monotonic() + + try: + binding = wait_for_agent_wallet_binding( + account_id=resolved_account_id, + agent_id=resolved_agent_id, + role=role, + timeout_s=timeout, + on_polling=_on_polling, + ) + except PairingTimedOutError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + except Exception as exc: + typer.echo(f"Role binding failed: {exc}", err=True) + raise typer.Exit(1) + + typer.echo(f"Bound {role}: {binding.get('walletAddress')}") + typer.echo(f" accountId: {resolved_account_id}") + typer.echo(f" agentId: {resolved_agent_id}") + + +@pair_app.command("roles", help="Show maker/taker wallet-role selections stored for this pairing") +def pair_roles() -> None: + _ensure_path() + from cli.web_auth import get_stored_pairing + + pairing = get_stored_pairing() + if pairing is None: + typer.echo("No paired wallet. Run `hl pair connect` first.", err=True) + raise typer.Exit(1) + roles = pairing.role_addresses or {} + if not roles: + typer.echo("No maker/taker role bindings stored.") + typer.echo("Run `hl pair bind-role maker` and `hl pair bind-role taker`.") + return + for role in ("maker", "taker"): + typer.echo(f"{role}: {roles.get(role, '-')}") + + +@pair_app.command("pending", help="List backend-visible scoped approval requests for this pairing") +def pair_pending(json_output: bool = typer.Option(False, "--json", help="Print raw JSON.")) -> None: + _ensure_path() + from cli.web_auth import PairingInvalidError, PairingMissingError, fetch_pending_scoped_requests + + try: + pending = fetch_pending_scoped_requests() + except (PairingMissingError, PairingInvalidError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + + if json_output: + typer.echo(json.dumps({"ok": True, "pending": pending}, indent=2)) + return + if not pending: + typer.echo("No pending scoped requests for this pairing.") + return + for request in pending: + eligible = "eligible" if request.get("programmatic_eligible") else f"browser-required ({request.get('programmatic_error')})" + typer.echo(f"{request.get('request_id')} - {eligible}") + if request.get("summary"): + typer.echo(f" {request['summary']}") + if request.get("requested_signer"): + typer.echo(f" signer: {request['requested_signer']}") + + +@pair_app.command("approve", help="Approve an eligible scoped request through web-auth backend state") +def pair_approve( + request_id: str = typer.Argument(..., help="Pending request id from `hl pair pending`."), + yes: bool = typer.Option(False, "--yes", "-y", help="Approve without an interactive prompt."), +) -> None: + _ensure_path() + from cli.web_auth import PairingInvalidError, PairingMissingError, approve_scoped_request + + if not yes: + typed = typer.prompt("Type approve to approve this scoped request") + if typed.strip().lower() != "approve": + typer.echo("Cancelled.") + raise typer.Exit(0) + try: + result = approve_scoped_request(request_id, approval="approve") + except (PairingMissingError, PairingInvalidError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + except Exception as exc: + typer.echo(str(exc), err=True) + typer.echo("Open web-auth for browser approval if this request is not programmatic-eligible.", err=True) + raise typer.Exit(1) + typer.echo(f"Approved scoped request {request_id}.") + approval = result.get("approval") or {} + if approval.get("signer"): + typer.echo(f" signer: {approval['signer']}") + + +@pair_app.command("revoke", help="Revoke the pairing locally and on the server") +def pair_revoke() -> None: + _ensure_path() + from cli.web_auth import clear_pairing, get_stored_pairing + + if get_stored_pairing() is None: + typer.echo("No active pairing.") + return + clear_pairing() + typer.echo("Pairing revoked.") + + +@pair_app.command("sign-test", help="Ask the paired wallet to sign harmless typed data") +def pair_sign_test() -> None: + _ensure_path() + from cli.web_auth import WALLET_AUTH_URL, get_selected_pairing_address, sign_with_pair + + signer = get_selected_pairing_address() + typed_data = { + "domain": { + "name": "HL Agent CLI", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000", + }, + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "PairingTest": [ + {"name": "wallet", "type": "address"}, + {"name": "message", "type": "string"}, + {"name": "time", "type": "uint64"}, + ], + }, + "primaryType": "PairingTest", + "message": { + "wallet": signer, + "message": "Verify HL Agent CLI web-auth pairing", + "time": int(time.time() * 1000), + }, + } + typer.echo(f"Open wallet approvals if needed: {WALLET_AUTH_URL}") + sig = sign_with_pair(typed_data, "HL Agent CLI pairing sign-test") + typer.echo(f"Signed: {sig}") + diff --git a/cli/commands/trade.py b/cli/commands/trade.py index 35c88e1..4342191 100644 --- a/cli/commands/trade.py +++ b/cli/commands/trade.py @@ -2,12 +2,25 @@ from __future__ import annotations import logging +import os import sys +import time from pathlib import Path +from typing import Optional import typer +def _confirm_trade(yes: bool) -> None: + if yes: + return + if not sys.stdin.isatty(): + typer.echo("Refusing to trade non-interactively without --yes.", err=True) + raise typer.Exit(2) + if not typer.confirm("Confirm?"): + raise typer.Exit(0) + + def trade_cmd( instrument: str = typer.Argument( "ETH-PERP", @@ -33,6 +46,30 @@ def trade_cmd( "Ioc", "--tif", help="Time in force: Ioc, Gtc, or Alo", ), + yes: bool = typer.Option( + False, "--yes", "-y", + help="Submit without interactive confirmation.", + ), + dry_run: bool = typer.Option( + False, "--dry-run", + help="Print the resolved order plan without submitting it.", + ), + max_notional_usd: Optional[float] = typer.Option( + None, "--max-notional", + help="Reject if size * price exceeds this USD notional cap.", + ), + decision_call_id: Optional[str] = typer.Option( + None, "--decision-call-id", + help="Optional LLM decision ID to join this trade to cost ledgers.", + ), + tick_index: Optional[int] = typer.Option( + None, "--tick-index", + help="Optional strategy tick index to join this trade to runtime/cost ledgers.", + ), + generation_id: Optional[str] = typer.Option( + None, "--generation-id", + help="Optional provider generation ID to join this trade to route ledgers.", + ), ): """Place a single order on Hyperliquid.""" project_root = str(Path(__file__).resolve().parent.parent.parent) @@ -48,7 +85,9 @@ def trade_cmd( from cli.config import TradingConfig from cli.hl_adapter import DirectHLProxy from cli.strategy_registry import resolve_instrument + from modules.cost_metering import ExperimentContext from parent.hl_proxy import HLProxy + from parent.store import JSONLStore instrument = resolve_instrument(instrument) cfg = TradingConfig() @@ -71,11 +110,29 @@ def trade_cmd( typer.echo(f"Using market price: {price}") network = "mainnet" if mainnet else "testnet" - typer.echo(f"Placing {side.upper()} {size} {instrument} @ {price} ({tif}) on {network}") + notional_usd = abs(size * price) + notional_cap = cfg.max_notional_usd if max_notional_usd is None else max_notional_usd + if notional_cap <= 0: + typer.echo("Error: max notional must be positive", err=True) + raise typer.Exit(1) + if notional_usd > notional_cap: + typer.echo( + f"Refusing order: notional ${notional_usd:.2f} exceeds " + f"max notional ${notional_cap:.2f}", + err=True, + ) + raise typer.Exit(1) - confirm = typer.confirm("Confirm?") - if not confirm: - raise typer.Exit(0) + typer.echo( + f"Placing {side.upper()} {size} {instrument} @ {price} ({tif}) on {network} " + f"(notional=${notional_usd:.2f}, max=${notional_cap:.2f})" + ) + + if dry_run: + typer.echo("Dry run: order not submitted.") + return + + _confirm_trade(yes) fill = hl.place_order( instrument=instrument, @@ -90,5 +147,33 @@ def trade_cmd( f"Filled: {fill.side.upper()} {fill.quantity} {fill.instrument} " f"@ {fill.price} (oid={fill.oid})" ) + experiment = ExperimentContext.from_env("manual_trade") + if experiment.enabled: + data_dir = os.environ.get("NUNCHI_COST_DATA_DIR") or os.environ.get("DATA_DIR", "data/cli") + trade_log = JSONLStore( + os.environ.get("NUNCHI_TRADE_LEDGER_PATH") or str(Path(data_dir) / "trades.jsonl") + ) + trade_log.append({ + "experiment_id": experiment.experiment_id, + "run_id": experiment.run_id, + "agent_id": experiment.agent_id, + "job_type": experiment.job_type, + "ts": int(time.time() * 1000), + "tick": tick_index, + "tick_index": tick_index, + "decision_call_id": decision_call_id, + "generation_id": generation_id, + "oid": fill.oid, + "cloid": getattr(fill, "cloid", None), + "instrument": fill.instrument, + "side": fill.side, + "price": str(fill.price), + "quantity": str(fill.quantity), + "timestamp_ms": fill.timestamp_ms, + "fee": str(fill.fee), + "strategy": "manual_trade", + "route": "cli.trade", + "network": network, + }) else: typer.echo("No fill (order may have been rejected or not matched)") diff --git a/cli/config.py b/cli/config.py index 0684322..bedf3bb 100644 --- a/cli/config.py +++ b/cli/config.py @@ -8,6 +8,18 @@ from typing import Any, Dict, Optional +DEFAULT_PAIR_AUTHORIZE_URL = "http://localhost:5174/ide/authorize" +DEFAULT_PAIR_API_URL = "http://localhost:8422" +DEFAULT_PAIR_WALLET_URL = "https://web-auth-opal.vercel.app/" +DEFAULT_PAIRING_PATH = "~/.hl-agent/pairing.json" + +ARBITRUM_CHAIN_ID = 42161 +ARBITRUM_SEPOLIA_CHAIN_ID = 421614 +ARBITRUM_USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" +HL_BRIDGE2_MAINNET_ADDRESS = "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7" +HL_BRIDGE2_TESTNET_ADDRESS = "0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89" + + @dataclass class TradingConfig: # Strategy @@ -47,6 +59,49 @@ class TradingConfig: # Builder fee builder: Dict[str, Any] = field(default_factory=dict) + # web-auth pairing and wallet relay + web_auth_authorize_url: str = field( + default_factory=lambda: os.environ.get( + "HL_WEB_AUTH_AUTHORIZE_URL", + os.environ.get("VITE_PAIR_AUTHORIZE_URL", DEFAULT_PAIR_AUTHORIZE_URL), + ) + ) + web_auth_api_url: str = field( + default_factory=lambda: os.environ.get( + "HL_WEB_AUTH_API_URL", + os.environ.get("VITE_PAIR_API_URL", DEFAULT_PAIR_API_URL), + ) + ) + web_auth_wallet_url: str = field( + default_factory=lambda: os.environ.get( + "HL_WEB_AUTH_WALLET_URL", + os.environ.get("VITE_PAIR_WALLET_URL", DEFAULT_PAIR_WALLET_URL), + ) + ) + web_auth_pairing_path: str = field( + default_factory=lambda: os.environ.get("HL_WEB_AUTH_PAIRING_PATH", DEFAULT_PAIRING_PATH) + ) + + # On-chain deposit config + arbitrum_chain_id: int = field( + default_factory=lambda: int(os.environ.get("HL_ARBITRUM_CHAIN_ID", str(ARBITRUM_CHAIN_ID))) + ) + arbitrum_testnet_chain_id: int = field( + default_factory=lambda: int(os.environ.get("HL_ARBITRUM_TESTNET_CHAIN_ID", str(ARBITRUM_SEPOLIA_CHAIN_ID))) + ) + arbitrum_usdc_address: str = field( + default_factory=lambda: os.environ.get("HL_ARBITRUM_USDC_ADDRESS", ARBITRUM_USDC_ADDRESS) + ) + arbitrum_testnet_usdc_address: Optional[str] = field( + default_factory=lambda: os.environ.get("HL_ARBITRUM_TESTNET_USDC_ADDRESS") + ) + hl_bridge2_mainnet_address: str = field( + default_factory=lambda: os.environ.get("HL_BRIDGE2_MAINNET_ADDRESS", HL_BRIDGE2_MAINNET_ADDRESS) + ) + hl_bridge2_testnet_address: str = field( + default_factory=lambda: os.environ.get("HL_BRIDGE2_TESTNET_ADDRESS", HL_BRIDGE2_TESTNET_ADDRESS) + ) + # Logging log_level: str = "INFO" log_file: Optional[str] = None diff --git a/cli/engine.py b/cli/engine.py index 64ea30e..0bb369f 100644 --- a/cli/engine.py +++ b/cli/engine.py @@ -21,6 +21,7 @@ from cli.display import shutdown_summary, tick_line from cli.order_manager import OrderManager from execution.order_book import ManagedOrderBook +from modules.cost_metering import ExperimentContext log = logging.getLogger("engine") ZERO = Decimal("0") @@ -28,6 +29,17 @@ MAX_CONSECUTIVE_TIMEOUTS = 3 +def _env_number(name: str, default: float) -> float: + raw = os.environ.get(name) + if not raw: + return default + try: + return float(raw) + except ValueError: + log.warning("Invalid %s=%r; using default %s", name, raw, default) + return default + + class TradingEngine: """Autonomous trading loop: fetch -> risk check -> strategy -> execute -> track.""" @@ -57,12 +69,28 @@ def __init__( # Persistence self.state_db = StateDB(path=f"{data_dir}/state.db") self.trade_log = JSONLStore(path=f"{data_dir}/trades.jsonl") + self.experiment = ExperimentContext.from_env(strategy.strategy_id) + self.runtime_log = None + self.incident_log = None + if self.experiment.enabled: + self.runtime_log = JSONLStore( + os.environ.get("NUNCHI_RUNTIME_LEDGER_PATH") + or f"{data_dir}/agent_runtime_ledger.jsonl" + ) + self.incident_log = JSONLStore( + os.environ.get("NUNCHI_INCIDENT_LEDGER_PATH") + or f"{data_dir}/incident_ledger.jsonl" + ) # Runtime state self.tick_count = 0 self.start_time_ms = 0 self._running = False self._consecutive_timeouts = 0 + self.tick_timeout_s = _env_number("TICK_TIMEOUT_S", float(TICK_TIMEOUT_S)) + self.max_consecutive_timeouts = int( + _env_number("MAX_CONSECUTIVE_TIMEOUTS", float(MAX_CONSECUTIVE_TIMEOUTS)) + ) self._tick_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="tick") # Optional Guard (composable mode — set via guard_config) @@ -109,22 +137,46 @@ def run(self, max_ticks: int = 0, resume: bool = True) -> None: try: future = self._tick_executor.submit(self._tick) - future.result(timeout=TICK_TIMEOUT_S) + future.result(timeout=self.tick_timeout_s) self._consecutive_timeouts = 0 except FuturesTimeoutError: self._consecutive_timeouts += 1 - log.error("Tick %d timed out after %ds (%d/%d consecutive)", - self.tick_count + 1, TICK_TIMEOUT_S, - self._consecutive_timeouts, MAX_CONSECUTIVE_TIMEOUTS) - if self._consecutive_timeouts >= MAX_CONSECUTIVE_TIMEOUTS: + log.error("Tick %d timed out after %.1fs (%d/%d consecutive)", + self.tick_count + 1, self.tick_timeout_s, + self._consecutive_timeouts, self.max_consecutive_timeouts) + self._log_incident( + "tick_timeout", + "error", + f"Tick timed out after {self.tick_timeout_s}s", + impact_on_cost_data="heartbeat may be missing or delayed", + recoverable=True, + rerun_required=False, + ) + if self._consecutive_timeouts >= self.max_consecutive_timeouts: log.critical("Engine entering safe mode: %d consecutive tick timeouts", self._consecutive_timeouts) self.risk_manager.state.safe_mode = True except APICircuitBreakerOpen as e: log.critical("API circuit breaker open — entering safe mode: %s", e) self.risk_manager.state.safe_mode = True + self._log_incident( + "api_circuit_breaker_open", + "critical", + str(e), + impact_on_cost_data="runtime degraded; trading halted by safe mode", + recoverable=True, + rerun_required=True, + ) except Exception as e: log.error("Tick %d failed: %s", self.tick_count, e, exc_info=True) + self._log_incident( + "tick_exception", + "error", + str(e), + impact_on_cost_data="tick failed before normal heartbeat completion", + recoverable=True, + rerun_required=True, + ) if self._running and self.tick_interval > 0: time.sleep(self.tick_interval) @@ -235,6 +287,8 @@ def _tick(self) -> None: fill.quantity, fill.price, ) self.trade_log.append({ + **self._experiment_fields(), + **self._decision_fields(valid_decisions), "tick": self.tick_count, "oid": fill.oid, "instrument": fill.instrument, @@ -375,6 +429,8 @@ def _guard_close_position(self, snapshot: MarketSnapshot) -> None: fill.quantity, fill.price, ) self.trade_log.append({ + **self._experiment_fields(), + **self._decision_fields(), "tick": self.tick_count, "oid": fill.oid, "instrument": fill.instrument, @@ -459,6 +515,8 @@ def _close_all_positions(self) -> None: fill.quantity, fill.price, ) self.trade_log.append({ + **self._experiment_fields(), + **self._decision_fields(), "tick": self.tick_count, "oid": fill.oid, "instrument": fill.instrument, @@ -492,6 +550,83 @@ def _log_tick(self, snapshot, decisions, fills, ok: bool) -> None: reduce_only=self.risk_manager.state.reduce_only, ) log.info(line) + self._log_runtime_event( + "heartbeat", + **self._decision_fields(decisions), + uptime_seconds=round((time.time() * 1000 - self.start_time_ms) / 1000, 3), + process_status="running", + risk_ok=ok, + orders_sent=len(decisions), + orders_filled=len(fills), + mid=snapshot.mid_price, + position_qty=float(pos.net_qty), + unrealized_pnl=float(pos.unrealized_pnl(mid_dec)), + realized_pnl=float(pos.realized_pnl), + ) + + def _experiment_fields(self) -> Dict[str, Any]: + if not self.experiment.enabled: + return {} + return { + "experiment_id": self.experiment.experiment_id, + "run_id": self.experiment.run_id, + "agent_id": self.experiment.agent_id, + "job_type": self.experiment.job_type, + } + + def _decision_fields(self, decisions=None) -> Dict[str, Any]: + fields: Dict[str, Any] = {"tick_index": self.tick_count} + decision_call_id = None + for decision in decisions or []: + meta = getattr(decision, "meta", None) or {} + decision_call_id = meta.get("decision_call_id") + if decision_call_id: + break + if decision_call_id is None: + decision_call_id = getattr(self.strategy, "last_decision_call_id", None) + if decision_call_id: + fields["decision_call_id"] = decision_call_id + return fields + + def _log_runtime_event(self, event_type: str, **fields: Any) -> None: + if self.runtime_log is None: + return + self.runtime_log.append({ + **self._experiment_fields(), + "ts": int(time.time() * 1000), + "strategy": self.strategy.strategy_id, + "event_type": event_type, + "restart_count": int(os.environ.get("NUNCHI_RESTART_COUNT", "0")), + "network": "mainnet" if os.environ.get("HL_TESTNET", "true").lower() == "false" else "testnet", + **fields, + }) + + def _log_incident( + self, + failure_type: str, + severity: str, + description: str, + *, + impact_on_cost_data: str, + recoverable: bool, + rerun_required: bool, + ) -> None: + if self.incident_log is None: + return + self.incident_log.append({ + **self._experiment_fields(), + "ts": int(time.time() * 1000), + "strategy": self.strategy.strategy_id, + "tick_index": self.tick_count, + "failure_type": failure_type, + "severity": severity, + "description": description, + "impact_on_cost_data": impact_on_cost_data, + "recoverable": recoverable, + "rerun_required": rerun_required, + "fix_owner": "", + "status": "open", + }) def _preflight_check(self) -> None: """Verify account has funds before starting. Warns loudly if not.""" diff --git a/cli/main.py b/cli/main.py index a6afc37..90e0398 100644 --- a/cli/main.py +++ b/cli/main.py @@ -28,6 +28,7 @@ from cli.commands.pulse import pulse_app from cli.commands.apex import apex_app from cli.commands.builder import builder_app +from cli.commands.pair import pair_app from cli.commands.reflect import reflect_app from cli.commands.wallet import wallet_app from cli.commands.setup import setup_app @@ -49,6 +50,7 @@ app.add_typer(pulse_app, name="pulse", help="Pulse — detect assets with capital inflow") app.add_typer(apex_app, name="apex", help="APEX — autonomous multi-slot trading") app.add_typer(builder_app, name="builder", help="Builder fee — revenue collection on trades") +app.add_typer(pair_app, name="pair", help="web-auth paired wallet management") app.add_typer(reflect_app, name="reflect", help="Reflect — performance review and self-improvement") app.add_typer(wallet_app, name="wallet", help="Encrypted keystore wallet management") app.add_typer(setup_app, name="setup", help="Environment validation and setup") diff --git a/cli/mcp_server.py b/cli/mcp_server.py index e80c212..875b328 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -8,6 +8,7 @@ import json import subprocess import sys +from pathlib import Path from typing import Optional @@ -21,13 +22,27 @@ def _run_hl(*args: str, timeout: int = 30) -> str: return output or "(no output)" +def _run_script(script_name: str, *args: str, timeout: int = 300) -> str: + """Run a repository script via subprocess and return stdout/stderr.""" + script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name + cmd = [sys.executable, str(script_path), *args] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + output = result.stdout.strip() + if result.returncode != 0 and result.stderr: + output = output + "\n" + result.stderr.strip() if output else result.stderr.strip() + return output or "(no output)" + + def create_mcp_server(): """Create and configure the FastMCP server.""" from mcp.server.fastmcp import FastMCP mcp = FastMCP( "yex-trader", - instructions="Autonomous Hyperliquid trading CLI — 14 strategies, APEX orchestrator, REFLECT reviews, BTCSWP funding hedge proposals.", + instructions=( + "Autonomous Hyperliquid trading CLI — 18 strategies, APEX orchestrator, " + "REFLECT reviews, and BTCSWP funding hedge proposals." + ), ) # ------------------------------------------------------------------ @@ -164,6 +179,11 @@ def setup_check() -> str: "passed": len(issues) == 0, }, indent=2) + @mcp.tool() + def pair_status() -> str: + """Show web-auth paired wallet status.""" + return _run_hl("pair", "status") + @mcp.tool() def funding_hedge_propose( asset: str = "BTC", @@ -252,15 +272,133 @@ def status() -> str: # ------------------------------------------------------------------ @mcp.tool() - def trade(instrument: str, side: str, size: float) -> str: + def trade( + instrument: str, + side: str, + size: float, + price: float = 0.0, + tif: str = "Ioc", + confirm: bool = False, + dry_run: bool = False, + max_notional_usd: Optional[float] = None, + decision_call_id: Optional[str] = None, + tick_index: Optional[int] = None, + generation_id: Optional[str] = None, + mainnet: bool = False, + ) -> str: """Place a single manual order. Args: instrument: Trading pair (e.g., ETH-PERP, BTC-PERP, VXX-USDYP) side: Order side — "buy" or "sell" size: Order size in contracts + price: Limit price. 0 uses the CLI market-price fallback. + tif: Time in force — Ioc, Gtc, or Alo. + confirm: Must be true to submit a live order. + dry_run: Resolve and print the order plan without submitting. + max_notional_usd: Optional USD notional cap. + decision_call_id: Optional LLM decision ID for ledger joins. + tick_index: Optional strategy tick index for ledger joins. + generation_id: Optional provider generation ID for ledger joins. + mainnet: Use Hyperliquid mainnet instead of testnet. """ - return _run_hl("trade", instrument, side, str(size)) + if not confirm and not dry_run: + return "Refusing to trade without confirm=true or dry_run=true." + args = [ + "trade", + instrument, + side, + str(size), + "--price", + str(price), + "--tif", + tif, + ] + if confirm: + args.append("--yes") + if dry_run: + args.append("--dry-run") + if max_notional_usd is not None: + args.extend(["--max-notional", str(max_notional_usd)]) + if decision_call_id: + args.extend(["--decision-call-id", decision_call_id]) + if tick_index is not None: + args.extend(["--tick-index", str(tick_index)]) + if generation_id: + args.extend(["--generation-id", generation_id]) + if mainnet: + args.append("--mainnet") + return _run_hl(*args) + + @mcp.tool() + def approve_agent(confirm: bool = False, mainnet: bool = False) -> str: + """Fund-moving auth: approve the local key as a Hyperliquid agent. + + Args: + confirm: Must be true to submit the approval request. + mainnet: Use Hyperliquid mainnet instead of testnet. + """ + if not confirm: + return "Refusing to approve agent without confirm=true." + args = ["pair", "approve-agent", "--yes"] + if mainnet: + args.append("--mainnet") + return _run_hl(*args, timeout=300) + + @mcp.tool() + def money_withdraw(amount: str, destination: str, confirm: bool = False, mainnet: bool = False) -> str: + """Fund-moving: withdraw USDC from Hyperliquid to Arbitrum. + + Args: + amount: USDC amount. + destination: Arbitrum destination address. + confirm: Must be true to submit the withdrawal request. + mainnet: Use Hyperliquid mainnet instead of testnet. + """ + if not confirm: + return "Refusing to move funds without confirm=true." + args = ["money", "withdraw", amount, destination, "--yes"] + if mainnet: + args.append("--mainnet") + return _run_hl(*args, timeout=300) + + @mcp.tool() + def money_transfer_usd(amount: str, destination: str, confirm: bool = False, mainnet: bool = False) -> str: + """Fund-moving: send USDC internally on Hyperliquid. + + Args: + amount: USDC amount. + destination: Hyperliquid destination address. + confirm: Must be true to submit the transfer request. + mainnet: Use Hyperliquid mainnet instead of testnet. + """ + if not confirm: + return "Refusing to move funds without confirm=true." + args = ["money", "transfer", "usd", amount, destination, "--yes"] + if mainnet: + args.append("--mainnet") + return _run_hl(*args, timeout=300) + + @mcp.tool() + def money_deposit(amount: str, confirm: bool = False, mainnet: bool = False) -> str: + """Fund-moving: deposit Arbitrum USDC into Hyperliquid Bridge2. + + Args: + amount: USDC amount, minimum 5. + confirm: Must be true to submit the Arbitrum transaction request. + mainnet: Use Arbitrum/Hyperliquid mainnet instead of testnet. + """ + if not confirm: + return "Refusing to move funds without confirm=true." + args = ["money", "deposit", amount, "--yes"] + if mainnet: + args.append("--mainnet") + return _run_hl(*args, timeout=300) + + @mcp.tool() + def money_bridge_status() -> str: + """Explain why cross-chain bridge support is not enabled yet.""" + return _run_hl("money", "bridge") @mcp.tool() def run_strategy( @@ -294,6 +432,63 @@ def run_strategy( args.append("--mainnet") return _run_hl(*args, timeout=max(60, (max_ticks or 10) * tick + 30)) + @mcp.tool() + def hedge_agent_smoke_test( + instrument: str = "ETH-PERP", + position_qty: float = 5.0, + inventory_threshold: float = 3.0, + notional_threshold: Optional[float] = None, + urgency_factor: float = 0.5, + max_hedge_size: float = 5.0, + slippage_bps: float = 10.0, + mainnet_account_check: bool = False, + sam_address: Optional[str] = None, + send_testnet_usdc: Optional[str] = None, + confirm_send_testnet_usdc: bool = False, + ) -> str: + """Run Sam's hedge_agent CLI smoke test through MCP. + + Exercises the real `hl run hedge_agent` path in mock mode with seeded + long and short positions, then validates the first hedge fill. Optional + mainnet verification is read-only (`hl account --mainnet`). Optional + testnet USDC transfer requires confirm_send_testnet_usdc=true. + + Args: + instrument: Trading instrument for the mock hedge run. + position_qty: Absolute seeded position size for long/short cases. + inventory_threshold: Quantity threshold used unless notional_threshold is set. + notional_threshold: Optional USD notional threshold. + urgency_factor: Hedge sizing multiplier. + max_hedge_size: Maximum hedge order size. + slippage_bps: IOC slippage budget in basis points. + mainnet_account_check: Also run read-only mainnet account verification. + sam_address: Destination address for optional testnet USDC transfer. + send_testnet_usdc: Optional testnet USDC amount to transfer to sam_address. + confirm_send_testnet_usdc: Must be true to submit the testnet transfer. + """ + if send_testnet_usdc and not confirm_send_testnet_usdc: + return "Refusing to move testnet USDC without confirm_send_testnet_usdc=true." + if send_testnet_usdc and not sam_address: + return "Refusing to move testnet USDC without sam_address." + + args = [ + "--instrument", instrument, + "--position-qty", str(position_qty), + "--urgency-factor", str(urgency_factor), + "--max-hedge-size", str(max_hedge_size), + "--slippage-bps", str(slippage_bps), + ] + if notional_threshold is None: + args.extend(["--inventory-threshold", str(inventory_threshold)]) + else: + args.extend(["--notional-threshold", str(notional_threshold)]) + if mainnet_account_check: + args.append("--mainnet-account-check") + if send_testnet_usdc: + args.extend(["--sam-address", sam_address or "", "--send-testnet-usdc", send_testnet_usdc]) + + return _run_script("test_hedge_agent.py", *args, timeout=600) + @mcp.tool() def radar_run(mock: bool = False) -> str: """Run opportunity radar — screen HL perps for trading setups.""" diff --git a/cli/web_auth.py b/cli/web_auth.py new file mode 100644 index 0000000..5f0a257 --- /dev/null +++ b/cli/web_auth.py @@ -0,0 +1,517 @@ +"""CLI <-> web-auth pairing, EIP-712 signing, and transaction relay client.""" +from __future__ import annotations + +import json +import os +import secrets +import time +import webbrowser +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Callable, Optional +from urllib.parse import urlencode + +import requests + +from cli.config import ( + DEFAULT_PAIR_API_URL, + DEFAULT_PAIR_AUTHORIZE_URL, + DEFAULT_PAIR_WALLET_URL, +) + + +AUTHORIZE_URL = os.environ.get( + "HL_WEB_AUTH_AUTHORIZE_URL", + os.environ.get("VITE_PAIR_AUTHORIZE_URL", DEFAULT_PAIR_AUTHORIZE_URL), +) +PAIR_API_BASE = os.environ.get( + "HL_WEB_AUTH_API_URL", + os.environ.get("VITE_PAIR_API_URL", DEFAULT_PAIR_API_URL), +) +WALLET_AUTH_URL = os.environ.get( + "HL_WEB_AUTH_WALLET_URL", + os.environ.get("VITE_PAIR_WALLET_URL", DEFAULT_PAIR_WALLET_URL), +) +STORAGE_PATH = Path(os.environ.get("HL_WEB_AUTH_PAIRING_PATH", "~/.hl-agent/pairing.json")).expanduser() + +PAIRING_MAX_AGE_S = 28 * 24 * 3600 +POLL_INTERVAL_S = 2 +PAIR_TIMEOUT_S = 5 * 60 +SIGN_TIMEOUT_S = 4 * 60 +TRANSACTION_TIMEOUT_S = 4 * 60 + + +@dataclass +class PairingResult: + """Persisted pairing state for web-auth.""" + + token: str + addresses: list[str] + label: str + paired_at_ms: int + selected_address: Optional[str] = None + account_id: Optional[str] = None + master_address: Optional[str] = None + active_session: Optional[dict[str, Any]] = None + agent_wallet_binding: Optional[dict[str, Any]] = None + role_addresses: Optional[dict[str, str]] = None + + def to_json(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_json(cls, raw: dict[str, Any]) -> "PairingResult": + return cls( + token=raw["token"], + addresses=list(raw["addresses"]), + label=raw.get("label", ""), + paired_at_ms=int(raw["paired_at_ms"]), + selected_address=raw.get("selected_address") or raw.get("selectedAddress"), + account_id=raw.get("account_id") or raw.get("accountId"), + master_address=raw.get("master_address") or raw.get("masterAddress"), + active_session=raw.get("active_session") or raw.get("activeSession"), + agent_wallet_binding=raw.get("agent_wallet_binding") or raw.get("agentWalletBinding"), + role_addresses=raw.get("role_addresses") or raw.get("roleAddresses") or {}, + ) + + @property + def selected_or_master_address(self) -> str: + selected = self.selected_address or self.master_address + if selected: + return selected + if not self.addresses: + raise PairingInvalidError("pairing has no addresses") + return self.addresses[0] + + +class PairingMissingError(Exception): + """No pairing stored.""" + + def __init__(self) -> None: + super().__init__("No paired wallet. Run `hl pair connect` to link one.") + + +class PairingInvalidError(Exception): + """web-auth rejected or cannot use the stored pair token.""" + + def __init__(self, reason: str) -> None: + super().__init__(f"Pairing invalid: {reason}. Re-pair via `hl pair connect`.") + + +class SignRejectedError(Exception): + """User rejected an EIP-712 signing request.""" + + def __init__(self, reason: str) -> None: + super().__init__(f"User rejected signing: {reason}") + + +class SignTimedOutError(Exception): + """User did not approve an EIP-712 signing request in time.""" + + def __init__(self) -> None: + super().__init__("Signing request timed out.") + + +class PairingTimedOutError(Exception): + """User did not complete pairing in time.""" + + def __init__(self) -> None: + super().__init__("Pairing timed out.") + + +class TransactionRejectedError(Exception): + """User rejected an EVM transaction request.""" + + def __init__(self, reason: str) -> None: + super().__init__(f"User rejected transaction: {reason}") + + +class TransactionTimedOutError(Exception): + """User did not send an EVM transaction in time.""" + + def __init__(self) -> None: + super().__init__("Transaction request timed out.") + + +def get_stored_pairing() -> Optional[PairingResult]: + """Return the persisted pairing, or None if missing, malformed, or stale.""" + if not STORAGE_PATH.exists(): + return None + try: + raw = json.loads(STORAGE_PATH.read_text("utf-8")) + result = PairingResult.from_json(raw) + except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError): + return None + + age_s = (time.time() * 1000 - result.paired_at_ms) / 1000 + if age_s > PAIRING_MAX_AGE_S: + try: + STORAGE_PATH.unlink() + except OSError: + pass + return None + return result + + +def require_pairing() -> PairingResult: + pairing = get_stored_pairing() + if pairing is None: + raise PairingMissingError() + return pairing + + +def get_selected_pairing_address() -> str: + return require_pairing().selected_or_master_address + + +def _persist(result: PairingResult) -> None: + STORAGE_PATH.parent.mkdir(parents=True, exist_ok=True) + STORAGE_PATH.write_text(json.dumps(result.to_json(), indent=2) + "\n", "utf-8") + try: + STORAGE_PATH.chmod(0o600) + except OSError: + pass + + +def select_pairing_address(address_or_index: str) -> PairingResult: + pairing = require_pairing() + selected = None + if address_or_index.isdigit(): + idx = int(address_or_index) + if 0 <= idx < len(pairing.addresses): + selected = pairing.addresses[idx] + if selected is None: + selected = next((addr for addr in pairing.addresses if addr.lower() == address_or_index.lower()), None) + if selected is None: + raise ValueError(f"wallet {address_or_index!r} is not in the current pairing") + pairing.selected_address = selected + _persist(pairing) + return pairing + + +def clear_pairing() -> None: + token: Optional[str] = None + if STORAGE_PATH.exists(): + try: + token = json.loads(STORAGE_PATH.read_text("utf-8")).get("token") + except (json.JSONDecodeError, OSError): + pass + try: + STORAGE_PATH.unlink() + except OSError: + pass + + if token: + try: + requests.post(f"{PAIR_API_BASE}/api/pair/revoke", json={"token": token}, timeout=5) + except requests.RequestException: + pass + + +def _auth_headers(pairing: PairingResult) -> dict[str, str]: + return {"Authorization": f"Bearer {pairing.token}", "Accept": "application/json"} + + +def open_wallet_ui( + *, + no_browser: bool = False, + account_id: Optional[str] = None, + agent_id: Optional[str] = None, + agent_name: Optional[str] = None, + include_pair_token: bool = False, +) -> str: + pairing = get_stored_pairing() + if include_pair_token and pairing is None: + raise PairingMissingError() + params: list[tuple[str, str]] = [] + if account_id or agent_id: + params.append(("view", "agent-wallets")) + if account_id: + params.append(("accountId", account_id)) + if agent_id: + params.append(("agentId", agent_id)) + if agent_name: + params.append(("agentName", agent_name)) + if include_pair_token and pairing is not None: + params.append(("pairToken", pairing.token)) + separator = "&" if "?" in WALLET_AUTH_URL else "?" + url = f"{WALLET_AUTH_URL}{separator}{urlencode(params)}" if params else WALLET_AUTH_URL + _open_browser(url, no_browser=no_browser) + return url + + +def fetch_agent_wallet_binding(account_id: str, agent_id: str) -> dict[str, Any]: + pairing = require_pairing() + resp = requests.get( + f"{PAIR_API_BASE}/api/agent-wallets/binding", + params={"accountId": account_id, "agentId": agent_id}, + headers=_auth_headers(pairing), + timeout=10, + ) + if resp.status_code == 401: + raise PairingInvalidError("token rejected by web-auth (401)") + if not resp.ok: + raise RuntimeError(f"/api/agent-wallets/binding returned {resp.status_code}: {resp.text[:200]}") + return resp.json() + + +def wait_for_agent_wallet_binding( + *, + account_id: str, + agent_id: str, + role: str, + timeout_s: int = PAIR_TIMEOUT_S, + on_polling: Optional[Callable[[], None]] = None, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + body = fetch_agent_wallet_binding(account_id, agent_id) + binding = body.get("binding") if body.get("bound") else None + address = binding.get("walletAddress") if isinstance(binding, dict) else None + if address: + pairing = require_pairing() + role_addresses = dict(pairing.role_addresses or {}) + role_addresses[role] = address + pairing.role_addresses = role_addresses + pairing.agent_wallet_binding = binding + _persist(pairing) + return binding + if on_polling: + on_polling() + time.sleep(POLL_INTERVAL_S) + raise PairingTimedOutError() + + +def fetch_pending_scoped_requests() -> list[dict[str, Any]]: + pairing = require_pairing() + resp = requests.get( + f"{PAIR_API_BASE}/api/sign/pending-scoped", + headers=_auth_headers(pairing), + timeout=10, + ) + if resp.status_code == 401: + raise PairingInvalidError("token rejected by web-auth (401)") + if not resp.ok: + raise RuntimeError(f"/api/sign/pending-scoped returned {resp.status_code}: {resp.text[:200]}") + return list((resp.json() or {}).get("pending") or []) + + +def approve_scoped_request(request_id: str, approval: str = "approve") -> dict[str, Any]: + pairing = require_pairing() + resp = requests.post( + f"{PAIR_API_BASE}/api/sign/approve-scoped", + headers=_auth_headers(pairing), + json={"request_id": request_id, "approval": approval}, + timeout=15, + ) + if resp.status_code == 401: + raise PairingInvalidError("token rejected by web-auth (401)") + if not resp.ok: + raise RuntimeError(f"/api/sign/approve-scoped returned {resp.status_code}: {resp.text[:200]}") + return resp.json() + + +def _random_code() -> str: + return secrets.token_urlsafe(24).rstrip("=") + + +def _random_request_id() -> str: + return secrets.token_urlsafe(16).rstrip("=") + + +def _open_browser(url: str, no_browser: bool = False) -> None: + if no_browser: + return + try: + webbrowser.open(url, new=2, autoraise=True) + except webbrowser.Error: + pass + + +def start_pairing( + app_name: str = "HL Agent CLI", + deep_link: Optional[str] = None, + on_polling: Optional[Callable[[], None]] = None, + no_browser: bool = False, + on_url: Optional[Callable[[str], None]] = None, + timeout_s: int = PAIR_TIMEOUT_S, +) -> PairingResult: + """Start the browser pairing handshake and persist the claimed token.""" + code = _random_code() + params: list[tuple[str, str]] = [("code", code), ("app", app_name)] + if deep_link: + params.append(("redirect", deep_link)) + url = f"{AUTHORIZE_URL}?{urlencode(params)}" + + if on_url: + on_url(url) + _open_browser(url, no_browser=no_browser) + + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + resp = requests.get(f"{PAIR_API_BASE}/api/pair/{code}", timeout=10) + except requests.RequestException: + if on_polling: + on_polling() + time.sleep(POLL_INTERVAL_S) + continue + + if resp.status_code == 404: + if on_polling: + on_polling() + time.sleep(POLL_INTERVAL_S) + continue + if not resp.ok: + raise RuntimeError(f"pair server returned {resp.status_code}: {resp.text[:200]}") + + body = resp.json() + if body.get("status") != "claimed": + if on_polling: + on_polling() + time.sleep(POLL_INTERVAL_S) + continue + + result = PairingResult( + token=body["token"], + addresses=list(body["addresses"]), + label=body.get("label", ""), + paired_at_ms=int(time.time() * 1000), + account_id=body.get("accountId"), + master_address=body.get("masterAddress"), + active_session=body.get("activeSession"), + agent_wallet_binding=body.get("agentWalletBinding"), + ) + _persist(result) + return result + + raise PairingTimedOutError() + + +def verify_pairing() -> Optional[dict[str, Any]]: + """Best-effort pair-token introspection.""" + pairing = get_stored_pairing() + if pairing is None: + return None + try: + resp = requests.get( + f"{PAIR_API_BASE}/api/pair/verify", + headers={"Authorization": f"Bearer {pairing.token}"}, + timeout=5, + ) + except requests.RequestException: + return None + if resp.status_code == 401: + raise PairingInvalidError("token rejected by web-auth (401)") + if not resp.ok: + return None + return resp.json() + + +def sign_with_pair( + typed_data: dict[str, Any], + summary: str = "", + timeout_s: int = SIGN_TIMEOUT_S, + on_awaiting: Optional[Callable[[], None]] = None, + scope: Optional[dict[str, Any]] = None, +) -> str: + """Submit EIP-712 typed data to the paired wallet and return the hex signature.""" + pairing = require_pairing() + request_id = _random_request_id() + payload: dict[str, Any] = { + "token": pairing.token, + "request_id": request_id, + "typed_data": typed_data, + "summary": summary, + } + if scope is not None: + payload["scope"] = scope + + submit = requests.post(f"{PAIR_API_BASE}/api/sign", json=payload, timeout=15) + if submit.status_code == 401: + raise PairingInvalidError("token rejected by web-auth (401)") + if not submit.ok: + raise RuntimeError(f"/api/sign returned {submit.status_code}: {submit.text[:200]}") + + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if on_awaiting: + on_awaiting() + time.sleep(POLL_INTERVAL_S) + try: + poll = requests.get(f"{PAIR_API_BASE}/api/sign/{request_id}", timeout=10) + except requests.RequestException: + continue + if not poll.ok and poll.status_code != 404: + raise RuntimeError(f"poll error {poll.status_code}: {poll.text[:200]}") + body = poll.json() if poll.content else {} + status = body.get("status") + if status == "signed" and body.get("signature"): + return body["signature"] + if status == "rejected": + raise SignRejectedError(body.get("reason", "user_rejected")) + if status == "error": + raise RuntimeError(body.get("error", "sign relay returned error")) + if status == "unknown_or_expired": + raise SignTimedOutError() + + raise SignTimedOutError() + + +def submit_transaction( + transaction: dict[str, Any], + summary: str = "", + timeout_s: int = TRANSACTION_TIMEOUT_S, + on_awaiting: Optional[Callable[[], None]] = None, +) -> str: + """Submit an EVM transaction request and return the broadcast tx hash.""" + pairing = require_pairing() + request_id = _random_request_id() + submit = requests.post( + f"{PAIR_API_BASE}/api/transaction", + json={ + "token": pairing.token, + "request_id": request_id, + "transaction": transaction, + "summary": summary, + }, + timeout=15, + ) + if submit.status_code == 401: + raise PairingInvalidError("token rejected by web-auth (401)") + if not submit.ok: + raise RuntimeError(f"/api/transaction returned {submit.status_code}: {submit.text[:200]}") + + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if on_awaiting: + on_awaiting() + time.sleep(POLL_INTERVAL_S) + try: + poll = requests.get(f"{PAIR_API_BASE}/api/transaction/{request_id}", timeout=10) + except requests.RequestException: + continue + if not poll.ok and poll.status_code != 404: + raise RuntimeError(f"poll error {poll.status_code}: {poll.text[:200]}") + body = poll.json() if poll.content else {} + status = body.get("status") + if status == "sent" and body.get("tx_hash"): + return body["tx_hash"] + if status == "rejected": + raise TransactionRejectedError(body.get("reason", "user_rejected")) + if status == "error": + raise RuntimeError(body.get("error", "transaction relay returned error")) + if status == "unknown_or_expired": + raise TransactionTimedOutError() + + raise TransactionTimedOutError() + + +def fetch_health() -> Optional[dict[str, Any]]: + try: + resp = requests.get(f"{PAIR_API_BASE}/api/health", timeout=5) + if resp.ok: + return resp.json() + except requests.RequestException: + pass + return None diff --git a/modules/cost_metering.py b/modules/cost_metering.py new file mode 100644 index 0000000..f8442f7 --- /dev/null +++ b/modules/cost_metering.py @@ -0,0 +1,261 @@ +"""Persistent LLM cost metering for hosted-agent pricing experiments.""" +from __future__ import annotations + +import json +import logging +import os +import time +import urllib.request +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +from parent.store import JSONLStore + +log = logging.getLogger("cost_metering") + +OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models" +ZERO = Decimal("0") + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _env_decimal(name: str) -> Optional[Decimal]: + raw = os.environ.get(name) + if not raw: + return None + try: + return Decimal(raw) + except InvalidOperation: + log.warning("Invalid decimal in %s=%r; ignoring", name, raw) + return None + + +@dataclass(frozen=True) +class ExperimentContext: + """Stable identifiers shared across ledgers for one agent process.""" + + experiment_id: str + run_id: str + agent_id: str + job_type: str + + @classmethod + def from_env(cls, strategy_id: str) -> "ExperimentContext": + run_id = os.environ.get("NUNCHI_RUN_ID") or f"manual-{int(time.time())}" + return cls( + experiment_id=os.environ.get("NUNCHI_EXPERIMENT_ID", ""), + run_id=run_id, + agent_id=os.environ.get("NUNCHI_AGENT_ID") or strategy_id, + job_type=os.environ.get("NUNCHI_JOB_TYPE", "unknown"), + ) + + @property + def enabled(self) -> bool: + return bool(self.experiment_id) + + +class OpenRouterPricing: + """Fetches and caches token prices from OpenRouter's models endpoint.""" + + def __init__(self) -> None: + self._prices: Optional[Dict[str, Tuple[Decimal, Decimal]]] = None + + def unit_prices(self, model: str) -> Tuple[Decimal, Decimal, str]: + override_in = _env_decimal("NUNCHI_PRICE_INPUT_USD_PER_TOKEN") + override_out = _env_decimal("NUNCHI_PRICE_OUTPUT_USD_PER_TOKEN") + if override_in is not None and override_out is not None: + return override_in, override_out, "env:NUNCHI_PRICE_*_USD_PER_TOKEN" + + prices = self._load_prices() + if model in prices: + input_price, output_price = prices[model] + return input_price, output_price, OPENROUTER_MODELS_URL + + return ZERO, ZERO, f"{OPENROUTER_MODELS_URL}:missing:{model}" + + def _load_prices(self) -> Dict[str, Tuple[Decimal, Decimal]]: + if self._prices is not None: + return self._prices + + try: + headers = {} + api_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("AI_API_KEY") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + req = urllib.request.Request(OPENROUTER_MODELS_URL, headers=headers) + with urllib.request.urlopen(req, timeout=10) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except Exception as exc: + log.warning("Could not fetch OpenRouter pricing: %s", exc) + self._prices = {} + return self._prices + + prices: Dict[str, Tuple[Decimal, Decimal]] = {} + for item in payload.get("data", []): + model_id = item.get("id") + pricing = item.get("pricing") or {} + if not model_id: + continue + try: + prompt = Decimal(str(pricing.get("prompt", "0"))) + completion = Decimal(str(pricing.get("completion", "0"))) + except InvalidOperation: + continue + prices[str(model_id)] = (prompt, completion) + + self._prices = prices + return self._prices + + +class CostMeter: + """Writes cost and route ledgers for one strategy process.""" + + def __init__( + self, + context: ExperimentContext, + data_dir: str, + strategy: str, + pricing: Optional[OpenRouterPricing] = None, + ) -> None: + self.context = context + self.strategy = strategy + self.pricing = pricing or OpenRouterPricing() + base_dir = Path(os.environ.get("NUNCHI_COST_DATA_DIR") or data_dir) + self.cost_log = JSONLStore( + os.environ.get("NUNCHI_COST_LEDGER_PATH") or str(base_dir / "cost_ledger.jsonl") + ) + self.route_log = JSONLStore( + os.environ.get("NUNCHI_ROUTE_LEDGER_PATH") or str(base_dir / "route_ledger.jsonl") + ) + + @classmethod + def from_env(cls, strategy_id: str) -> Optional["CostMeter"]: + context = ExperimentContext.from_env(strategy_id) + if not context.enabled: + return None + data_dir = os.environ.get("DATA_DIR", "data/cli") + return cls(context=context, data_dir=data_dir, strategy=strategy_id) + + def record_llm_call( + self, + *, + provider: str, + requested_model: str, + resolved_model: str, + route: str, + input_tokens: int, + output_tokens: int, + tick_index: Optional[int], + elapsed_ms: float, + decision_call_id: Optional[str] = None, + cache_read_input_tokens: Optional[int] = None, + cache_creation_input_tokens: Optional[int] = None, + cached_tokens: Optional[int] = None, + uncached_input_tokens: Optional[int] = None, + cache_hit_rate: Optional[float] = None, + cache_savings_usd: Optional[Any] = None, + actual_usd_cost: Optional[Any] = None, + route_metadata: Optional[Dict[str, Any]] = None, + ) -> None: + input_tokens = int(input_tokens or 0) + output_tokens = int(output_tokens or 0) + route_metadata = route_metadata or {} + + if provider == "openrouter": + unit_in, unit_out, price_source = self.pricing.unit_prices(resolved_model) + else: + unit_in = _env_decimal(f"NUNCHI_{provider.upper()}_INPUT_USD_PER_TOKEN") or ZERO + unit_out = _env_decimal(f"NUNCHI_{provider.upper()}_OUTPUT_USD_PER_TOKEN") or ZERO + price_source = f"env:NUNCHI_{provider.upper()}_*_USD_PER_TOKEN" + + actual_cost = None + if actual_usd_cost is not None: + try: + actual_cost = Decimal(str(actual_usd_cost)) + except InvalidOperation: + actual_cost = None + + usd_cost = actual_cost + if usd_cost is None: + usd_cost = (Decimal(input_tokens) * unit_in) + (Decimal(output_tokens) * unit_out) + cache_savings = None + if cache_savings_usd is not None: + try: + cache_savings = Decimal(str(cache_savings_usd)) + except InvalidOperation: + cache_savings = None + ts_ms = _now_ms() + row = { + "experiment_id": self.context.experiment_id, + "run_id": self.context.run_id, + "ts": ts_ms, + "agent_id": self.context.agent_id, + "strategy": self.strategy, + "job_type": self.context.job_type, + "tick_index": tick_index, + "decision_call_id": decision_call_id, + "provider": provider, + "requested_model": requested_model, + "resolved_model": resolved_model, + "route": route, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "unit_price_input_usd": str(unit_in), + "unit_price_output_usd": str(unit_out), + "usd_cost": str(usd_cost), + "pricing_snapshot_source": "openrouter:usage.cost" if actual_cost is not None else price_source, + "elapsed_ms": round(elapsed_ms, 2), + } + cache_fields = { + "cache_read_input_tokens": cache_read_input_tokens, + "cache_creation_input_tokens": cache_creation_input_tokens, + "cached_tokens": cached_tokens, + "uncached_input_tokens": uncached_input_tokens, + "cache_hit_rate": cache_hit_rate, + "cache_savings_usd": str(cache_savings) if cache_savings is not None else None, + } + for key, value in cache_fields.items(): + if value is not None: + row[key] = value + if route_metadata: + row["route_metadata"] = route_metadata + self.cost_log.append(row) + + if provider == "openrouter": + route_row = { + "experiment_id": self.context.experiment_id, + "run_id": self.context.run_id, + "ts": ts_ms, + "agent_id": self.context.agent_id, + "job_type": self.context.job_type, + "tick_index": tick_index, + "decision_call_id": decision_call_id, + "requested_route": route, + "resolved_model": resolved_model, + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + "estimated_usd": str(usd_cost), + "actual_usd": str(usd_cost), + "fallback_used": requested_model != resolved_model and route == "openrouter/fusion", + "fallback_reason": "", + } + for key in ( + "cache_read_input_tokens", + "cache_creation_input_tokens", + "cached_tokens", + "uncached_input_tokens", + "cache_hit_rate", + "cache_savings_usd", + ): + if key in row: + route_row[key] = row[key] + if route_metadata: + route_row["metadata"] = route_metadata + for key in ("generation_id", "router", "routing_strategy", "provider_name"): + if key in route_metadata: + route_row[key] = route_metadata[key] + self.route_log.append(route_row) diff --git a/requirements.txt b/requirements.txt index 429042d..13ea0e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,15 @@ typer>=0.9.0 +click>=8.4.2 pydantic>=2.0.0 pyyaml>=6.0 -hyperliquid-python-sdk>=0.4.0 +hyperliquid-python-sdk==0.20.1 eth-account>=0.10.0 requests>=2.28.0 eciespy>=0.4.0 # Optional: LLM strategy (claude_agent) # anthropic>=0.40.0 +openai>=1.0.0 + +# Optional hosted readout mode (cli.main mcp serve) +mcp diff --git a/scripts/funded_btcswp_combined_run.py b/scripts/funded_btcswp_combined_run.py new file mode 100644 index 0000000..454e81c --- /dev/null +++ b/scripts/funded_btcswp_combined_run.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Run a funded BTCSWP decision+trade smoke path with joinable ledgers. + +This script does not mock the LLM or execution path. It records a real +OpenRouter decision call when enabled, then shells into `hl trade` with the +same decision_call_id so cost_ledger/route_ledger rows join to trades.jsonl. +Live orders require --confirm. +""" +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Optional + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from modules.cost_metering import CostMeter, ExperimentContext # noqa: E402 + + +def _usage_value(usage, *names: str) -> int: + for name in names: + value = getattr(usage, name, None) + if value is not None: + return int(value or 0) + if hasattr(usage, "model_dump"): + data = usage.model_dump() + for name in names: + if name in data: + return int(data.get(name) or 0) + return 0 + + +def _usage_cost(usage) -> Optional[object]: + cost = getattr(usage, "cost", None) + if cost is not None: + return cost + if hasattr(usage, "model_extra"): + cost = usage.model_extra.get("cost") + if cost is not None: + return cost + if hasattr(usage, "model_dump"): + return usage.model_dump().get("cost") + return None + + +def _record_openrouter_decision(args: argparse.Namespace, decision_call_id: str) -> Optional[str]: + if args.skip_llm: + return None + + api_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("AI_API_KEY") + if not api_key: + raise RuntimeError("OPENROUTER_API_KEY or AI_API_KEY is required unless --skip-llm is used") + + import openai + + client = openai.OpenAI( + api_key=api_key, + base_url="https://openrouter.ai/api/v1", + default_headers={ + "HTTP-Referer": os.environ.get("OPENROUTER_HTTP_REFERER", "https://agent.nunchi.trade"), + "X-Title": os.environ.get("OPENROUTER_APP_TITLE", "Nunchi Funded BTCSWP Cost Run"), + }, + ) + prompt = ( + "You are approving a bounded Hyperliquid testnet smoke trade for cost measurement. " + f"Instrument={args.instrument}, side={args.side}, size={args.size}, price={args.price}, " + f"max_notional_usd={args.max_notional_usd}. Return a short JSON object with approve=true " + "only if this remains within the stated cap." + ) + started = time.time() + response = client.chat.completions.create( + model=args.model, + messages=[ + {"role": "system", "content": "You approve or refuse tiny testnet trading smoke tests."}, + {"role": "user", "content": prompt}, + ], + temperature=0, + max_tokens=96, + ) + elapsed_ms = (time.time() - started) * 1000 + usage = response.usage + generation_id = getattr(response, "id", None) + + meter = CostMeter.from_env("funded_btcswp_combined_run") + if meter is not None and usage is not None: + resolved_model = getattr(response, "model", None) or args.model + meter.record_llm_call( + provider="openrouter", + requested_model=args.model, + resolved_model=resolved_model, + route=args.model, + input_tokens=_usage_value(usage, "prompt_tokens", "input_tokens"), + output_tokens=_usage_value(usage, "completion_tokens", "output_tokens"), + tick_index=args.tick_index, + elapsed_ms=elapsed_ms, + decision_call_id=decision_call_id, + actual_usd_cost=_usage_cost(usage), + route_metadata={"generation_id": generation_id} if generation_id else None, + ) + print(f"Recorded LLM decision {decision_call_id} generation={generation_id or 'unknown'}") + return generation_id + + +def _run_trade( + *, + args: argparse.Namespace, + side: str, + key_env_name: Optional[str], + decision_call_id: str, + generation_id: Optional[str], + tif: str, +) -> None: + env = os.environ.copy() + if key_env_name: + private_key = env.get(key_env_name) + if not private_key: + raise RuntimeError(f"{key_env_name} is not set") + env["HL_PRIVATE_KEY"] = private_key + env.setdefault("HL_TESTNET", "true") + + cmd = [ + sys.executable, + "-m", + "cli.main", + "trade", + args.instrument, + side, + str(args.size), + "--price", + str(args.price), + "--tif", + tif, + "--max-notional", + str(args.max_notional_usd), + "--decision-call-id", + decision_call_id, + "--tick-index", + str(args.tick_index), + ] + if generation_id: + cmd.extend(["--generation-id", generation_id]) + if args.mainnet: + cmd.append("--mainnet") + if args.confirm: + cmd.append("--yes") + if args.dry_run or not args.confirm: + cmd.append("--dry-run") + + print("Running:", " ".join(cmd[:3] + ["...", *cmd[4:]])) + result = subprocess.run(cmd, cwd=str(ROOT), env=env, text=True, capture_output=True, timeout=120) + if result.stdout: + print(result.stdout.strip()) + if result.stderr: + print(result.stderr.strip(), file=sys.stderr) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Funded BTCSWP combined cost/fill smoke run") + parser.add_argument("--instrument", default="osrs:BTCSWP") + parser.add_argument("--side", choices=["buy", "sell"], default="buy") + parser.add_argument("--size", type=float, default=0.001) + parser.add_argument("--price", type=float, required=True) + parser.add_argument("--max-notional-usd", type=float, default=25.0) + parser.add_argument("--tick-index", type=int, default=1) + parser.add_argument("--model", default=os.environ.get("AI_MODEL", "openrouter/auto")) + parser.add_argument("--confirm", action="store_true", help="Submit live orders. Default is dry-run.") + parser.add_argument("--dry-run", action="store_true", help="Force dry-run trade calls.") + parser.add_argument("--mainnet", action="store_true", help="Use mainnet. Default is testnet.") + parser.add_argument("--skip-llm", action="store_true", help="Do not call OpenRouter.") + parser.add_argument("--maker-taker", action="store_true", help="Place maker ALO then taker IOC using env-key names.") + parser.add_argument("--maker-key-env", default="HL_TESTNET_MAKER_PRIVATE_KEY") + parser.add_argument("--taker-key-env", default="HL_TESTNET_TAKER_PRIVATE_KEY") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.mainnet and not args.confirm: + raise SystemExit("Mainnet requires --confirm.") + context = ExperimentContext.from_env("funded_btcswp_combined_run") + run_id = context.run_id if context.enabled else os.environ.get("NUNCHI_RUN_ID", f"manual-{int(time.time())}") + decision_call_id = f"funded_btcswp_combined_run:{run_id}:tick-{args.tick_index}" + generation_id = _record_openrouter_decision(args, decision_call_id) + + if args.maker_taker: + maker_side = "sell" if args.side == "buy" else "buy" + _run_trade( + args=args, + side=maker_side, + key_env_name=args.maker_key_env, + decision_call_id=decision_call_id, + generation_id=generation_id, + tif="Alo", + ) + _run_trade( + args=args, + side=args.side, + key_env_name=args.taker_key_env, + decision_call_id=decision_call_id, + generation_id=generation_id, + tif="Ioc", + ) + else: + _run_trade( + args=args, + side=args.side, + key_env_name=None, + decision_call_id=decision_call_id, + generation_id=generation_id, + tif="Ioc", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pricing_aggregate.py b/scripts/pricing_aggregate.py new file mode 100644 index 0000000..b4608fa --- /dev/null +++ b/scripts/pricing_aggregate.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Aggregate hosted-agent pricing ledgers into COGS and launch pricing.""" +from __future__ import annotations + +import argparse +import json +import statistics +import time +from collections import defaultdict +from decimal import Decimal +from pathlib import Path +from typing import Dict, Iterable, List + +HOURS_PER_MONTH = Decimal(24 * 30) +RAILWAY_CPU_USD_PER_VCPU_MONTH = Decimal("20") +RAILWAY_RAM_USD_PER_GB_MONTH = Decimal("10") +RAILWAY_VOLUME_USD_PER_GB_MONTH = Decimal("0.15") +RAILWAY_EGRESS_USD_PER_GB = Decimal("0.05") + + +def _read_jsonl(path: Path) -> Iterable[dict]: + if not path.exists(): + return [] + rows = [] + with path.open() as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue + return rows + + +def _all_rows(input_dir: Path, filename: str) -> List[dict]: + rows: List[dict] = [] + for path in input_dir.rglob(filename): + rows.extend(_read_jsonl(path)) + return rows + + +def _decimal(value) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return Decimal("0") + + +def _percentile(values: List[Decimal], percentile: float) -> Decimal: + if not values: + return Decimal("0") + ordered = sorted(values) + idx = int(round((len(ordered) - 1) * percentile)) + return ordered[idx] + + +def _money(value: Decimal) -> str: + return f"${float(value):,.4f}" + + +def _infra_usd_per_agent_hour(args: argparse.Namespace) -> tuple[Decimal, str]: + if args.infra_usd_per_agent_hour is not None: + return Decimal(str(args.infra_usd_per_agent_hour)), "manual:--infra-usd-per-agent-hour" + + monthly = ( + Decimal(str(args.railway_vcpu_per_agent)) * RAILWAY_CPU_USD_PER_VCPU_MONTH + + Decimal(str(args.railway_ram_gb_per_agent)) * RAILWAY_RAM_USD_PER_GB_MONTH + + Decimal(str(args.railway_volume_gb_per_agent)) * RAILWAY_VOLUME_USD_PER_GB_MONTH + + Decimal(str(args.railway_egress_gb_per_agent_month)) * RAILWAY_EGRESS_USD_PER_GB + ) + return monthly / HOURS_PER_MONTH, "railway:cpu+ram+volume+egress" + + +def aggregate(args: argparse.Namespace) -> int: + input_dir = Path(args.input_dir) + cost_rows = _all_rows(input_dir, "cost_ledger.jsonl") + runtime_rows = _all_rows(input_dir, "agent_runtime_ledger.jsonl") + incident_rows = _all_rows(input_dir, "incident_ledger.jsonl") + trade_rows = _all_rows(input_dir, "trades.jsonl") + infra_hourly, infra_source = _infra_usd_per_agent_hour(args) + + job_types = sorted({ + *(str(r.get("job_type", "unknown")) for r in cost_rows), + *(str(r.get("job_type", "unknown")) for r in runtime_rows), + *(str(r.get("job_type", "unknown")) for r in trade_rows if r.get("job_type")), + }) + if not job_types: + print(f"No pricing ledgers found under {input_dir}") + return 1 + + report_rows = [] + for job_type in job_types: + costs = [r for r in cost_rows if str(r.get("job_type", "unknown")) == job_type] + runtimes = [r for r in runtime_rows if str(r.get("job_type", "unknown")) == job_type] + trades = [r for r in trade_rows if str(r.get("job_type", "unknown")) == job_type] + + agents = {str(r.get("agent_id", "")) for r in [*costs, *runtimes, *trades] if r.get("agent_id")} + llm_total = sum((_decimal(r.get("usd_cost")) for r in costs), Decimal("0")) + fee_total = sum((_decimal(r.get("fee")) for r in trades), Decimal("0")) + input_token_total = sum((_decimal(r.get("input_tokens")) for r in costs), Decimal("0")) + cached_token_total = sum((_decimal(r.get("cached_tokens")) for r in costs), Decimal("0")) + cache_read_total = sum((_decimal(r.get("cache_read_input_tokens")) for r in costs), Decimal("0")) + cache_write_total = sum((_decimal(r.get("cache_creation_input_tokens")) for r in costs), Decimal("0")) + cache_savings_total = sum((_decimal(r.get("cache_savings_usd")) for r in costs), Decimal("0")) + cache_hit_rate = cached_token_total / input_token_total if input_token_total > 0 else Decimal("0") + cost_by_decision = defaultdict(lambda: Decimal("0")) + for row in costs: + decision_call_id = row.get("decision_call_id") + if decision_call_id: + cost_by_decision[str(decision_call_id)] += _decimal(row.get("usd_cost")) + linked_trade_cost = Decimal("0") + linked_trade_count = 0 + for row in trades: + decision_call_id = row.get("decision_call_id") + if decision_call_id and str(decision_call_id) in cost_by_decision: + linked_trade_count += 1 + linked_trade_cost += cost_by_decision[str(decision_call_id)] + avg_llm_per_linked_fill = ( + linked_trade_cost / Decimal(linked_trade_count) + if linked_trade_count + else Decimal("0") + ) + + timestamps = [int(r.get("ts", 0)) for r in [*costs, *runtimes] if r.get("ts")] + if timestamps: + duration_hours = Decimal(max(1, max(timestamps) - min(timestamps))) / Decimal(1000 * 60 * 60) + else: + duration_hours = Decimal("0") + + agent_count = Decimal(max(1, len(agents))) + infra_total = infra_hourly * duration_hours * agent_count + observability_total = Decimal(str(args.observability_usd_per_agent_hour)) * duration_hours * agent_count + total = llm_total + fee_total + infra_total + observability_total + + heartbeat_count = Decimal(len([r for r in runtimes if r.get("event_type") == "heartbeat"]) or len(costs) or 1) + usd_per_heartbeat = total / heartbeat_count + usd_per_hour = total / duration_hours if duration_hours > 0 else Decimal("0") + usd_per_day = usd_per_hour * Decimal(24) + usd_per_month = usd_per_day * Decimal(30) + + hourly = defaultdict(lambda: Decimal("0")) + for row in costs: + ts = int(row.get("ts", 0)) + if ts: + hourly[ts // (1000 * 60 * 60)] += _decimal(row.get("usd_cost")) + hourly_values = list(hourly.values()) or [Decimal("0")] + p50_hourly = Decimal(str(statistics.median(hourly_values))) + p95_hourly = _percentile(hourly_values, 0.95) + max_hourly = max(hourly_values) + fee_hourly = fee_total / duration_hours if duration_hours > 0 else Decimal("0") + p95_monthly_cogs = ( + p95_hourly + + (infra_hourly * agent_count) + + (Decimal(str(args.observability_usd_per_agent_hour)) * agent_count) + + fee_hourly + ) * Decimal(24 * 30) + + margin_prices = { + "70": p95_monthly_cogs / Decimal("0.30"), + "80": p95_monthly_cogs / Decimal("0.20"), + "85": p95_monthly_cogs / Decimal("0.15"), + "90": p95_monthly_cogs / Decimal("0.10"), + } + recommended = margin_prices[str(args.target_margin)] + + report_rows.append({ + "job_type": job_type, + "agent_count": len(agents), + "duration_hours": duration_hours, + "heartbeat_count": int(heartbeat_count), + "llm_total": llm_total, + "infra_total": infra_total, + "fees_total": fee_total, + "cached_token_total": cached_token_total, + "cache_read_total": cache_read_total, + "cache_write_total": cache_write_total, + "cache_hit_rate": cache_hit_rate, + "cache_savings_total": cache_savings_total, + "linked_trade_count": linked_trade_count, + "avg_llm_per_linked_fill": avg_llm_per_linked_fill, + "observability_total": observability_total, + "total": total, + "usd_per_heartbeat": usd_per_heartbeat, + "usd_per_hour": usd_per_hour, + "usd_per_day": usd_per_day, + "usd_per_month": usd_per_month, + "p50_hourly": p50_hourly, + "p95_hourly": p95_hourly, + "max_hourly": max_hourly, + "p95_monthly_cogs": p95_monthly_cogs, + "margin_prices": margin_prices, + "recommended": recommended, + "infra_hourly": infra_hourly, + "infra_source": infra_source, + }) + + markdown = _render_markdown(input_dir, report_rows, incident_rows, args) + if args.output: + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(markdown) + print(f"Pricing report saved to {out_path}") + else: + print(markdown) + return 0 + + +def _render_markdown(input_dir: Path, rows: List[dict], incidents: List[dict], args: argparse.Namespace) -> str: + generated = time.strftime("%Y-%m-%d") + lines = [ + "---", + "title: Hosted Agent Pricing Results", + f"date: {generated}", + "tags: [pricing, hosted-agents, cost-experiment, agent-cli]", + "---", + "", + "# Hosted Agent Pricing Results", + "", + "**Source:** [[2026-06-25-hosted-agent-pricing-qualification-loop]]", + "", + f"Input directory: `{input_dir}`", + f"Target margin: {args.target_margin}%", + "", + "## Executive Recommendation", + "", + ] + + for row in rows: + lines.append( + f"- `{row['job_type']}`: p95 monthly COGS {_money(row['p95_monthly_cogs'])}; " + f"recommended launch floor at {args.target_margin}% margin: {_money(row['recommended'])}." + ) + + lines.extend([ + "", + "## Cost By Job Type", + "", + "| Job Type | Agents | Hours | Heartbeats | Linked Fills | Avg LLM/Linked Fill | Cache Hit | Cached Tokens | Cache Savings | LLM | Infra | Fees | Total | USD/Heartbeat | USD/Month | p95 Monthly COGS | Recommended |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ]) + + for row in rows: + lines.append( + f"| `{row['job_type']}` | {row['agent_count']} | {float(row['duration_hours']):.2f} | " + f"{row['heartbeat_count']} | {row['linked_trade_count']} | {_money(row['avg_llm_per_linked_fill'])} | " + f"{float(row['cache_hit_rate']) * 100:.1f}% | {int(row['cached_token_total'])} | {_money(row['cache_savings_total'])} | " + f"{_money(row['llm_total'])} | {_money(row['infra_total'])} | " + f"{_money(row['fees_total'])} | {_money(row['total'])} | {_money(row['usd_per_heartbeat'])} | " + f"{_money(row['usd_per_month'])} | {_money(row['p95_monthly_cogs'])} | {_money(row['recommended'])} |" + ) + + lines.extend([ + "", + "## Margin Sensitivity", + "", + "| Job Type | 70% | 80% | 85% | 90% |", + "| --- | ---: | ---: | ---: | ---: |", + ]) + for row in rows: + prices = row["margin_prices"] + lines.append( + f"| `{row['job_type']}` | {_money(prices['70'])} | {_money(prices['80'])} | " + f"{_money(prices['85'])} | {_money(prices['90'])} |" + ) + + open_incidents = [i for i in incidents if i.get("status", "open") == "open"] + lines.extend([ + "", + "## Runtime And Failure Summary", + "", + f"- Incident rows: {len(incidents)}", + f"- Open incidents: {len(open_incidents)}", + "", + "## Assumptions", + "", + f"- Infra allocation: {_money(rows[0]['infra_hourly']) if rows else '$0.0000'}/agent-hour ({rows[0]['infra_source'] if rows else 'unknown'}).", + f"- Railway assumption: {args.railway_vcpu_per_agent} vCPU, {args.railway_ram_gb_per_agent} GB RAM, " + f"{args.railway_volume_gb_per_agent} GB volume, {args.railway_egress_gb_per_agent_month} GB monthly egress per agent.", + f"- Observability allocation: ${args.observability_usd_per_agent_hour}/agent-hour.", + "- Cache savings are reported only when provider usage metadata includes a savings value; otherwise cached tokens and hit rate are shown without assumed dollar savings.", + "- Pricing uses p95 monthly COGS rather than average COGS.", + "- Testnet measurements still need mainnet fee and production infra validation before final launch pricing.", + ]) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Aggregate hosted-agent pricing ledgers") + parser.add_argument("--input-dir", required=True) + parser.add_argument("--output") + parser.add_argument("--infra-usd-per-agent-hour", type=float, default=None) + parser.add_argument("--railway-vcpu-per-agent", type=float, default=1.0) + parser.add_argument("--railway-ram-gb-per-agent", type=float, default=1.0) + parser.add_argument("--railway-volume-gb-per-agent", type=float, default=0.0) + parser.add_argument("--railway-egress-gb-per-agent-month", type=float, default=0.0) + parser.add_argument("--observability-usd-per-agent-hour", type=float, default=0.0) + parser.add_argument("--target-margin", choices=["70", "80", "85", "90"], default="80") + args = parser.parse_args() + return aggregate(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/strategies/claude_agent.py b/strategies/claude_agent.py index ade3e06..89c98f3 100644 --- a/strategies/claude_agent.py +++ b/strategies/claude_agent.py @@ -30,6 +30,7 @@ from typing import Any, Dict, List, Optional from common.models import MarketSnapshot, StrategyDecision +from modules.cost_metering import CostMeter from sdk.strategy_sdk.base import BaseStrategy, StrategyContext log = logging.getLogger("llm_agent") @@ -123,6 +124,13 @@ def _detect_provider(model: str) -> str: return "gemini" +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + + # --------------------------------------------------------------------------- # Strategy # --------------------------------------------------------------------------- @@ -165,6 +173,14 @@ def __init__( self._openrouter_client = None self._blockrun_client = None + # Optional hosted-agent pricing meter. Enabled only when + # NUNCHI_EXPERIMENT_ID is present in the environment. + self._cost_meter = CostMeter.from_env(strategy_id) + self._current_tick_index: Optional[int] = None + self._current_decision_call_id: Optional[str] = None + self.last_decision_call_id: Optional[str] = None + self._last_llm_decision_tick: Optional[int] = None + # ------------------------------------------------------------------ # Client initialization # ------------------------------------------------------------------ @@ -234,6 +250,244 @@ def _get_openrouter_client(self): ) return self._openrouter_client + def _resolve_openrouter_model(self) -> str: + """Allow a confirmed Fusion override while preserving the requested route.""" + if self.model == "openrouter/fusion": + return ( + os.environ.get("OPENROUTER_FUSION_MODEL") + or os.environ.get("NUNCHI_OPENROUTER_FUSION_MODEL") + or self.model + ) + return self.model + + def _openrouter_fusion_plugins( + self, + *, + default_preset: Optional[str] = None, + ) -> Optional[List[Dict[str, Any]]]: + """Build optional OpenRouter Fusion plugin config from env.""" + if self.model != "openrouter/fusion": + return None + + preset = ( + os.environ.get("OPENROUTER_FUSION_PRESET") + or os.environ.get("NUNCHI_OPENROUTER_FUSION_PRESET") + or default_preset + ) + analysis_models_raw = ( + os.environ.get("OPENROUTER_FUSION_ANALYSIS_MODELS") + or os.environ.get("NUNCHI_OPENROUTER_FUSION_ANALYSIS_MODELS") + ) + judge_model = ( + os.environ.get("OPENROUTER_FUSION_JUDGE_MODEL") + or os.environ.get("NUNCHI_OPENROUTER_FUSION_JUDGE_MODEL") + ) + + plugin: Dict[str, Any] = {"id": "fusion"} + if preset: + plugin["preset"] = preset + if analysis_models_raw: + plugin["analysis_models"] = [ + item.strip() for item in analysis_models_raw.split(",") if item.strip() + ] + if judge_model: + plugin["model"] = judge_model + + for env_name, field in ( + ("OPENROUTER_FUSION_MAX_TOOL_CALLS", "max_tool_calls"), + ("OPENROUTER_FUSION_MAX_COMPLETION_TOKENS", "max_completion_tokens"), + ): + raw = os.environ.get(env_name) + if raw: + try: + plugin[field] = int(raw) + except ValueError: + log.warning("Ignoring invalid %s=%r", env_name, raw) + + if len(plugin) == 1: + return None + return [plugin] + + def _force_openrouter_fusion(self) -> bool: + return _env_bool("OPENROUTER_FORCE_FUSION") or _env_bool("NUNCHI_OPENROUTER_FORCE_FUSION") + + def _llm_decision_interval_ticks(self) -> int: + raw = ( + os.environ.get("NUNCHI_LLM_DECISION_INTERVAL_TICKS") + or os.environ.get("LLM_DECISION_INTERVAL_TICKS") + or "1" + ) + try: + return max(1, int(raw)) + except ValueError: + log.warning("Ignoring invalid LLM decision interval: %r", raw) + return 1 + + def _should_run_llm_decision(self, context: Optional[StrategyContext]) -> bool: + interval = self._llm_decision_interval_ticks() + if interval <= 1 or context is None: + return True + + tick = context.round_number + if self._last_llm_decision_tick is None: + return True + return tick - self._last_llm_decision_tick >= interval + + def _fetch_openrouter_generation_metadata(self, generation_id: Optional[str]) -> Dict[str, Any]: + """Fetch post-generation routing metadata when OpenRouter exposes it.""" + if not generation_id or not _env_bool("OPENROUTER_FETCH_GENERATION_METADATA", True): + return {} + + try: + import json as _json + import urllib.parse + import urllib.request + + api_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("AI_API_KEY") + if not api_key: + return {"generation_id": generation_id} + + query = urllib.parse.urlencode({"id": generation_id}) + req = urllib.request.Request( + f"https://openrouter.ai/api/v1/generation?{query}", + headers={ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + payload = _json.loads(resp.read().decode("utf-8")) + data = payload.get("data") or {} + except Exception as exc: + log.debug("Could not fetch OpenRouter generation metadata: %s", exc) + return {"generation_id": generation_id} + + metadata: Dict[str, Any] = {"generation_id": generation_id} + for source_key, dest_key in ( + ("router", "router"), + ("strategy", "routing_strategy"), + ("provider_name", "provider_name"), + ("model", "metadata_model"), + ("total_cost", "metadata_total_cost"), + ): + if source_key in data: + metadata[dest_key] = data.get(source_key) + for key in ("attempts", "pipeline"): + if key in data: + metadata[key] = data.get(key) + return metadata + + def _record_usage( + self, + *, + provider: str, + requested_model: str, + resolved_model: str, + route: str, + input_tokens: int, + output_tokens: int, + elapsed_ms: float, + usage: Any = None, + ) -> None: + if self._cost_meter is None: + return + cache_metrics = self._extract_cache_metrics(usage, input_tokens=input_tokens) + self._cost_meter.record_llm_call( + provider=provider, + requested_model=requested_model, + resolved_model=resolved_model, + route=route, + input_tokens=input_tokens, + output_tokens=output_tokens, + tick_index=self._current_tick_index, + elapsed_ms=elapsed_ms, + decision_call_id=self._current_decision_call_id, + **cache_metrics, + ) + + def _record_openrouter_usage( + self, + *, + requested_model: str, + resolved_model: str, + input_tokens: int, + output_tokens: int, + elapsed_ms: float, + usage: Any, + route: Optional[str] = None, + route_metadata: Optional[Dict[str, Any]] = None, + ) -> None: + actual_cost = getattr(usage, "cost", None) + if actual_cost is None and hasattr(usage, "model_extra"): + actual_cost = usage.model_extra.get("cost") + if actual_cost is None and hasattr(usage, "model_dump"): + actual_cost = usage.model_dump().get("cost") + if self._cost_meter is None: + return + cache_metrics = self._extract_cache_metrics(usage, input_tokens=input_tokens) + self._cost_meter.record_llm_call( + provider="openrouter", + requested_model=requested_model, + resolved_model=resolved_model, + route=route or requested_model, + input_tokens=input_tokens, + output_tokens=output_tokens, + tick_index=self._current_tick_index, + elapsed_ms=elapsed_ms, + decision_call_id=self._current_decision_call_id, + **cache_metrics, + actual_usd_cost=actual_cost, + route_metadata=route_metadata, + ) + + def _extract_cache_metrics(self, usage: Any, *, input_tokens: int) -> Dict[str, Any]: + if usage is None: + return {} + + usage_data: Dict[str, Any] = {} + if hasattr(usage, "model_dump"): + try: + usage_data = usage.model_dump() or {} + except Exception: + usage_data = {} + + def get_value(name: str) -> Any: + if hasattr(usage, name): + return getattr(usage, name) + return usage_data.get(name) + + prompt_details = get_value("prompt_tokens_details") or usage_data.get("prompt_tokens_details") or {} + if hasattr(prompt_details, "model_dump"): + prompt_details = prompt_details.model_dump() + elif not isinstance(prompt_details, dict): + prompt_details = { + "cached_tokens": getattr(prompt_details, "cached_tokens", None), + } + + cache_read = get_value("cache_read_input_tokens") + cache_creation = get_value("cache_creation_input_tokens") + cached_tokens = prompt_details.get("cached_tokens") + cache_savings = get_value("cache_savings_usd") or get_value("cache_discount_usd") + + if cache_read is None and cached_tokens is None and cache_creation is None and cache_savings is None: + return {} + + cache_read_int = int(cache_read or 0) + cache_creation_int = int(cache_creation or 0) + cached_int = int(cached_tokens if cached_tokens is not None else cache_read_int) + uncached_input_tokens = max(0, int(input_tokens or 0) - cached_int) + cache_hit_rate = (cached_int / input_tokens) if input_tokens else 0.0 + metrics: Dict[str, Any] = { + "cache_read_input_tokens": cache_read_int, + "cache_creation_input_tokens": cache_creation_int, + "cached_tokens": cached_int, + "uncached_input_tokens": uncached_input_tokens, + "cache_hit_rate": round(cache_hit_rate, 6), + } + if cache_savings is not None: + metrics["cache_savings_usd"] = cache_savings + return metrics + # ------------------------------------------------------------------ # Build prompt # ------------------------------------------------------------------ @@ -312,6 +566,16 @@ def _call_claude(self, user_msg: str, snapshot: MarketSnapshot) -> List[Strategy self._api_calls += 1 self._total_input_tokens += response.usage.input_tokens self._total_output_tokens += response.usage.output_tokens + self._record_usage( + provider="claude", + requested_model=self.model, + resolved_model=self.model, + route=self.model, + input_tokens=response.usage.input_tokens, + output_tokens=response.usage.output_tokens, + elapsed_ms=elapsed_ms, + usage=response.usage, + ) log.info( "Claude: %dms, %d/%d tokens (total: %d calls, %d/%d tokens)", @@ -388,13 +652,25 @@ def _call_gemini(self, user_msg: str, snapshot: MarketSnapshot) -> List[Strategy # Track tokens usage = response.usage_metadata if usage: - self._total_input_tokens += usage.prompt_token_count or 0 - self._total_output_tokens += usage.candidates_token_count or 0 + input_tokens = usage.prompt_token_count or 0 + output_tokens = usage.candidates_token_count or 0 + self._total_input_tokens += input_tokens + self._total_output_tokens += output_tokens + self._record_usage( + provider="gemini", + requested_model=self.model, + resolved_model=self.model, + route=self.model, + input_tokens=input_tokens, + output_tokens=output_tokens, + elapsed_ms=elapsed_ms, + usage=usage, + ) log.info( "Gemini: %dms, %d/%d tokens (total: %d calls, %d/%d tokens)", elapsed_ms, - usage.prompt_token_count or 0, - usage.candidates_token_count or 0, + input_tokens, + output_tokens, self._api_calls, self._total_input_tokens, self._total_output_tokens, @@ -452,11 +728,23 @@ def _call_openai(self, user_msg: str, snapshot: MarketSnapshot) -> List[Strategy self._api_calls += 1 usage = response.usage if usage: - self._total_input_tokens += usage.prompt_tokens or 0 - self._total_output_tokens += usage.completion_tokens or 0 + input_tokens = usage.prompt_tokens or 0 + output_tokens = usage.completion_tokens or 0 + self._total_input_tokens += input_tokens + self._total_output_tokens += output_tokens + self._record_usage( + provider="openai", + requested_model=self.model, + resolved_model=getattr(response, "model", self.model) or self.model, + route=self.model, + input_tokens=input_tokens, + output_tokens=output_tokens, + elapsed_ms=elapsed_ms, + usage=usage, + ) log.info( "OpenAI: %dms, %d/%d tokens (total: %d calls, %d/%d tokens)", - elapsed_ms, usage.prompt_tokens or 0, usage.completion_tokens or 0, + elapsed_ms, input_tokens, output_tokens, self._api_calls, self._total_input_tokens, self._total_output_tokens, ) @@ -472,28 +760,69 @@ def _call_openrouter(self, user_msg: str, snapshot: MarketSnapshot) -> List[Stra import json as _json client = self._get_openrouter_client() + requested_model = self.model + resolved_model = self._resolve_openrouter_model() + + if requested_model == "openrouter/fusion" and self._force_openrouter_fusion(): + fusion_analysis = self._call_openrouter_fusion_preflight( + client=client, + requested_model=requested_model, + resolved_model=resolved_model, + user_msg=user_msg, + ) + if fusion_analysis: + user_msg = ( + f"{user_msg}\n\n" + "=== FORCED OPENROUTER FUSION ANALYSIS ===\n" + f"{fusion_analysis[:4000]}" + ) + t0 = time.time() - response = client.chat.completions.create( - model=self.model, - max_tokens=self.max_tokens, - messages=[ + extra_body: Dict[str, Any] = {} + plugins = self._openrouter_fusion_plugins() + if plugins: + extra_body["plugins"] = plugins + + request_kwargs: Dict[str, Any] = { + "model": resolved_model, + "max_tokens": self.max_tokens, + "messages": [ {"role": "system", "content": self.system_prompt}, {"role": "user", "content": user_msg}, ], - tools=self._build_openai_tools(), - tool_choice="required", + "tools": self._build_openai_tools(), + "tool_choice": "required", + } + if extra_body: + request_kwargs["extra_body"] = extra_body + + response = client.chat.completions.create( + **request_kwargs ) elapsed_ms = (time.time() - t0) * 1000 self._api_calls += 1 usage = response.usage if usage: - self._total_input_tokens += usage.prompt_tokens or 0 - self._total_output_tokens += usage.completion_tokens or 0 + input_tokens = usage.prompt_tokens or 0 + output_tokens = usage.completion_tokens or 0 + response_model = getattr(response, "model", None) or resolved_model + metadata = self._fetch_openrouter_generation_metadata(getattr(response, "id", None)) + self._total_input_tokens += input_tokens + self._total_output_tokens += output_tokens + self._record_openrouter_usage( + requested_model=requested_model, + resolved_model=response_model, + input_tokens=input_tokens, + output_tokens=output_tokens, + elapsed_ms=elapsed_ms, + usage=usage, + route_metadata=metadata, + ) log.info( "OpenRouter: %dms, %d/%d tokens (total: %d calls, %d/%d tokens)", - elapsed_ms, usage.prompt_tokens or 0, usage.completion_tokens or 0, + elapsed_ms, input_tokens, output_tokens, self._api_calls, self._total_input_tokens, self._total_output_tokens, ) @@ -505,6 +834,69 @@ def _call_openrouter(self, user_msg: str, snapshot: MarketSnapshot) -> List[Stra decisions.extend(self._parse_tool_call(tc.function.name, args, snapshot)) return decisions + def _call_openrouter_fusion_preflight( + self, + *, + client: Any, + requested_model: str, + resolved_model: str, + user_msg: str, + ) -> str: + """Force OpenRouter Fusion before the trading tool decision.""" + max_tokens = int(os.environ.get("OPENROUTER_FUSION_PREFLIGHT_MAX_TOKENS", "384")) + plugins = self._openrouter_fusion_plugins(default_preset="general-budget") or [ + {"id": "fusion", "preset": "general-budget"} + ] + t0 = time.time() + + response = client.chat.completions.create( + model=resolved_model, + max_tokens=max_tokens, + messages=[ + { + "role": "system", + "content": ( + "You are a Fusion routing preflight for a Hyperliquid trading agent. " + "Use the OpenRouter Fusion tool to compare market interpretations, " + "then summarize only the consensus, disagreements, and any action bias. " + "Do not place orders." + ), + }, + {"role": "user", "content": user_msg}, + ], + tool_choice="required", + extra_body={"plugins": plugins}, + ) + + elapsed_ms = (time.time() - t0) * 1000 + self._api_calls += 1 + usage = response.usage + if usage: + input_tokens = usage.prompt_tokens or 0 + output_tokens = usage.completion_tokens or 0 + response_model = getattr(response, "model", None) or resolved_model + metadata = self._fetch_openrouter_generation_metadata(getattr(response, "id", None)) + self._total_input_tokens += input_tokens + self._total_output_tokens += output_tokens + self._record_openrouter_usage( + requested_model=requested_model, + resolved_model=response_model, + input_tokens=input_tokens, + output_tokens=output_tokens, + elapsed_ms=elapsed_ms, + usage=usage, + route="openrouter/fusion:preflight", + route_metadata=metadata, + ) + log.info( + "OpenRouter Fusion preflight: %dms, %d/%d tokens (total: %d calls, %d/%d tokens)", + elapsed_ms, input_tokens, output_tokens, + self._api_calls, self._total_input_tokens, self._total_output_tokens, + ) + + msg = response.choices[0].message + return msg.content or "" + # ------------------------------------------------------------------ # ClawRouter / BlockRun backend (x402 — pay with USDC, no API key) # ------------------------------------------------------------------ @@ -557,11 +949,23 @@ def _call_blockrun(self, user_msg: str, snapshot: MarketSnapshot) -> List[Strate self._api_calls += 1 usage = response.usage if usage: - self._total_input_tokens += usage.prompt_tokens or 0 - self._total_output_tokens += usage.completion_tokens or 0 + input_tokens = usage.prompt_tokens or 0 + output_tokens = usage.completion_tokens or 0 + self._total_input_tokens += input_tokens + self._total_output_tokens += output_tokens + self._record_usage( + provider="blockrun", + requested_model=self.model, + resolved_model=getattr(response, "model", self.model) or self.model, + route=self.model, + input_tokens=input_tokens, + output_tokens=output_tokens, + elapsed_ms=elapsed_ms, + usage=usage, + ) log.info( "ClawRouter: %dms, %d/%d tokens (total: %d calls, %d/%d tokens)", - elapsed_ms, usage.prompt_tokens or 0, usage.completion_tokens or 0, + elapsed_ms, input_tokens, output_tokens, self._api_calls, self._total_input_tokens, self._total_output_tokens, ) @@ -634,9 +1038,29 @@ def on_tick( return [] self._price_history.append((snapshot.mid_price, snapshot.timestamp_ms)) + if not self._should_run_llm_decision(context): + tick = context.round_number if context else "?" + log.info( + "LLM decision cadence: skipping tick %s (interval=%d ticks)", + tick, + self._llm_decision_interval_ticks(), + ) + return [] + user_msg = self._build_user_message(snapshot, context) + self._current_tick_index = context.round_number if context else None + run_id = ( + self._cost_meter.context.run_id + if self._cost_meter is not None + else f"manual-{int(time.time())}" + ) + tick_label = self._current_tick_index if self._current_tick_index is not None else "unknown" + self._current_decision_call_id = f"{self.strategy_id}:{run_id}:tick-{tick_label}" + self.last_decision_call_id = self._current_decision_call_id try: + if context: + self._last_llm_decision_tick = context.round_number provider = _detect_provider(self.model) if provider == "blockrun": decisions = self._call_blockrun(user_msg, snapshot) @@ -652,6 +1076,8 @@ def on_tick( decisions = self._call_gemini(user_msg, snapshot) for d in decisions: + if self._current_decision_call_id: + d.meta = {**(d.meta or {}), "decision_call_id": self._current_decision_call_id} if d.action == "place_order": self._fill_history.append({ "side": d.side, @@ -664,3 +1090,6 @@ def on_tick( except Exception as e: log.error("LLM API call failed: %s", e) return [] + finally: + self._current_tick_index = None + self._current_decision_call_id = None diff --git a/tests/test_cost_metering.py b/tests/test_cost_metering.py new file mode 100644 index 0000000..c8de8af --- /dev/null +++ b/tests/test_cost_metering.py @@ -0,0 +1,171 @@ +import json +from decimal import Decimal + +from modules.cost_metering import CostMeter, ExperimentContext, OpenRouterPricing + + +class StaticPricing(OpenRouterPricing): + def unit_prices(self, model: str): + return self.input_price, self.output_price, "test" + + def __init__(self, input_price="0.001", output_price="0.002"): + self.input_price = Decimal(input_price) + self.output_price = Decimal(output_price) + + +def test_cost_meter_writes_cost_and_route_ledgers(tmp_path): + context = ExperimentContext( + experiment_id="exp-1", + run_id="run-1", + agent_id="agent-1", + job_type="taker", + ) + meter = CostMeter( + context=context, + data_dir=str(tmp_path), + strategy="claude_agent", + pricing=StaticPricing(), + ) + + meter.record_llm_call( + provider="openrouter", + requested_model="openrouter/fusion", + resolved_model="anthropic/claude-haiku", + route="openrouter/fusion", + input_tokens=10, + output_tokens=5, + tick_index=7, + elapsed_ms=123.4, + decision_call_id="claude_agent:run-1:tick-7", + ) + + cost_row = json.loads((tmp_path / "cost_ledger.jsonl").read_text().strip()) + route_row = json.loads((tmp_path / "route_ledger.jsonl").read_text().strip()) + + assert cost_row["experiment_id"] == "exp-1" + assert cost_row["job_type"] == "taker" + assert cost_row["tick_index"] == 7 + assert cost_row["decision_call_id"] == "claude_agent:run-1:tick-7" + assert cost_row["usd_cost"] == "0.020" + assert route_row["requested_route"] == "openrouter/fusion" + assert route_row["decision_call_id"] == "claude_agent:run-1:tick-7" + assert route_row["resolved_model"] == "anthropic/claude-haiku" + + +def test_cost_meter_prefers_actual_openrouter_cost(tmp_path): + context = ExperimentContext( + experiment_id="exp-1", + run_id="run-1", + agent_id="agent-1", + job_type="taker", + ) + meter = CostMeter( + context=context, + data_dir=str(tmp_path), + strategy="claude_agent", + pricing=StaticPricing(input_price="0", output_price="0"), + ) + + meter.record_llm_call( + provider="openrouter", + requested_model="openrouter/fusion", + resolved_model="anthropic/claude-haiku", + route="openrouter/fusion", + input_tokens=10, + output_tokens=5, + tick_index=7, + elapsed_ms=123.4, + actual_usd_cost="0.0042", + ) + + cost_row = json.loads((tmp_path / "cost_ledger.jsonl").read_text().strip()) + route_row = json.loads((tmp_path / "route_ledger.jsonl").read_text().strip()) + + assert cost_row["usd_cost"] == "0.0042" + assert cost_row["pricing_snapshot_source"] == "openrouter:usage.cost" + assert route_row["actual_usd"] == "0.0042" + + +def test_cost_meter_records_openrouter_route_metadata(tmp_path): + context = ExperimentContext( + experiment_id="exp-1", + run_id="run-1", + agent_id="agent-1", + job_type="taker", + ) + meter = CostMeter( + context=context, + data_dir=str(tmp_path), + strategy="claude_agent", + pricing=StaticPricing(), + ) + + meter.record_llm_call( + provider="openrouter", + requested_model="openrouter/fusion", + resolved_model="anthropic/claude-haiku", + route="openrouter/fusion:preflight", + input_tokens=10, + output_tokens=5, + tick_index=7, + elapsed_ms=123.4, + actual_usd_cost="0.0042", + route_metadata={"generation_id": "gen-1", "router": "openrouter/fusion"}, + ) + + cost_row = json.loads((tmp_path / "cost_ledger.jsonl").read_text().strip()) + route_row = json.loads((tmp_path / "route_ledger.jsonl").read_text().strip()) + + assert cost_row["route_metadata"]["router"] == "openrouter/fusion" + assert route_row["router"] == "openrouter/fusion" + assert route_row["generation_id"] == "gen-1" + + +def test_cost_meter_records_cache_metrics(tmp_path): + context = ExperimentContext( + experiment_id="exp-1", + run_id="run-1", + agent_id="agent-1", + job_type="heartbeat", + ) + meter = CostMeter( + context=context, + data_dir=str(tmp_path), + strategy="claude_agent", + pricing=StaticPricing(), + ) + + meter.record_llm_call( + provider="openrouter", + requested_model="openrouter/auto", + resolved_model="openai/gpt-4o-mini", + route="openrouter/auto", + input_tokens=100, + output_tokens=10, + tick_index=1, + elapsed_ms=10, + cached_tokens=80, + cache_read_input_tokens=80, + cache_creation_input_tokens=5, + uncached_input_tokens=20, + cache_hit_rate=0.8, + cache_savings_usd="0.0001", + ) + + cost_row = json.loads((tmp_path / "cost_ledger.jsonl").read_text().strip()) + route_row = json.loads((tmp_path / "route_ledger.jsonl").read_text().strip()) + + assert cost_row["cached_tokens"] == 80 + assert cost_row["cache_read_input_tokens"] == 80 + assert cost_row["cache_creation_input_tokens"] == 5 + assert cost_row["uncached_input_tokens"] == 20 + assert cost_row["cache_hit_rate"] == 0.8 + assert cost_row["cache_savings_usd"] == "0.0001" + assert route_row["cached_tokens"] == 80 + + +def test_experiment_context_disabled_without_experiment_id(monkeypatch): + monkeypatch.delenv("NUNCHI_EXPERIMENT_ID", raising=False) + context = ExperimentContext.from_env("claude_agent") + assert context.enabled is False + assert context.agent_id == "claude_agent" diff --git a/tests/test_engine.py b/tests/test_engine.py index cedf7cc..db088c6 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -105,6 +105,7 @@ def test_place_order_decision_fills(self): side="buy", size=1.0, limit_price=2500.0, + meta={"decision_call_id": "test_stub:run-1:tick-1"}, ) ] hl = MockHL() @@ -118,6 +119,8 @@ def test_place_order_decision_fills(self): records = engine.trade_log.read_all() assert len(records) == 1 assert records[0]["side"] == "buy" + assert records[0]["tick_index"] == 1 + assert records[0]["decision_call_id"] == "test_stub:run-1:tick-1" def test_run_respects_max_ticks(self): engine = _make_engine() @@ -214,8 +217,19 @@ def test_timeout_constants_exist(self): def test_engine_has_timeout_state(self): engine = _make_engine() assert engine._consecutive_timeouts == 0 + assert engine.tick_timeout_s == 30 + assert engine.max_consecutive_timeouts == 3 assert engine._tick_executor is not None + def test_timeout_can_be_configured_from_env(self, monkeypatch): + monkeypatch.setenv("TICK_TIMEOUT_S", "75") + monkeypatch.setenv("MAX_CONSECUTIVE_TIMEOUTS", "5") + + engine = _make_engine() + + assert engine.tick_timeout_s == 75 + assert engine.max_consecutive_timeouts == 5 + class TestShutdownClose: def test_shutdown_closes_position(self): diff --git a/tests/test_mcp_money_tools.py b/tests/test_mcp_money_tools.py new file mode 100644 index 0000000..9cb67c7 --- /dev/null +++ b/tests/test_mcp_money_tools.py @@ -0,0 +1,205 @@ +"""Smoke tests for MCP money-movement wrappers.""" +from __future__ import annotations + +import sys +import types + + +class FakeFastMCP: + last = None + + def __init__(self, *args, **kwargs): + self.tools = {} + FakeFastMCP.last = self + + def tool(self): + def decorator(fn): + self.tools[fn.__name__] = fn + return fn + + return decorator + + +def test_mcp_money_tools_require_confirm(monkeypatch): + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + + assert server.tools["money_withdraw"]("5", "0x1111111111111111111111111111111111111111") == ( + "Refusing to move funds without confirm=true." + ) + assert server.tools["money_deposit"]("5") == "Refusing to move funds without confirm=true." + assert server.tools["approve_agent"]() == "Refusing to approve agent without confirm=true." + + +def test_mcp_money_withdraw_confirm_builds_subprocess(monkeypatch): + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + import cli.mcp_server as mcp_server + + calls = [] + monkeypatch.setattr(mcp_server, "_run_hl", lambda *args, timeout=30: calls.append(args) or "ok") + server = mcp_server.create_mcp_server() + + assert server.tools["money_withdraw"]( + "5", + "0x1111111111111111111111111111111111111111", + confirm=True, + mainnet=True, + ) == "ok" + assert calls == [ + ( + "money", + "withdraw", + "5", + "0x1111111111111111111111111111111111111111", + "--yes", + "--mainnet", + ) + ] + + +def test_mcp_trade_requires_confirm_or_dry_run(monkeypatch): + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + + assert server.tools["trade"]("ETH-PERP", "buy", 0.01) == ( + "Refusing to trade without confirm=true or dry_run=true." + ) + + +def test_mcp_trade_confirm_builds_safe_subprocess(monkeypatch): + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + import cli.mcp_server as mcp_server + + calls = [] + monkeypatch.setattr(mcp_server, "_run_hl", lambda *args, timeout=30: calls.append(args) or "ok") + server = mcp_server.create_mcp_server() + + assert server.tools["trade"]( + "ETH-PERP", + "buy", + 0.01, + price=2500.0, + tif="Alo", + confirm=True, + max_notional_usd=50.0, + mainnet=True, + ) == "ok" + assert calls == [ + ( + "trade", + "ETH-PERP", + "buy", + "0.01", + "--price", + "2500.0", + "--tif", + "Alo", + "--yes", + "--max-notional", + "50.0", + "--mainnet", + ) + ] + + +def test_mcp_hedge_smoke_test_builds_script_call(monkeypatch): + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + import cli.mcp_server as mcp_server + + calls = [] + monkeypatch.setattr(mcp_server, "_run_script", lambda *args, timeout=300: calls.append(args) or "ok") + server = mcp_server.create_mcp_server() + + assert server.tools["hedge_agent_smoke_test"]( + instrument="BTC-PERP", + position_qty=4.0, + notional_threshold=10000.0, + mainnet_account_check=True, + ) == "ok" + assert calls == [ + ( + "test_hedge_agent.py", + "--instrument", + "BTC-PERP", + "--position-qty", + "4.0", + "--urgency-factor", + "0.5", + "--max-hedge-size", + "5.0", + "--slippage-bps", + "10.0", + "--notional-threshold", + "10000.0", + "--mainnet-account-check", + ) + ] + + +def test_mcp_hedge_smoke_test_requires_confirm_for_testnet_transfer(monkeypatch): + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + + assert server.tools["hedge_agent_smoke_test"](send_testnet_usdc="5", sam_address="0xabc") == ( + "Refusing to move testnet USDC without confirm_send_testnet_usdc=true." + ) diff --git a/tests/test_pair_money_cli.py b/tests/test_pair_money_cli.py new file mode 100644 index 0000000..70f7edd --- /dev/null +++ b/tests/test_pair_money_cli.py @@ -0,0 +1,177 @@ +"""CLI tests for web-auth pair and money commands.""" +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from cli.commands.money import money_app +from cli.commands.pair import pair_app + + +runner = CliRunner() + + +def test_pair_list_no_pairing(monkeypatch): + monkeypatch.setattr("cli.web_auth.get_stored_pairing", lambda: None) + + result = runner.invoke(pair_app, ["list"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout)["ok"] is False + + +def test_pair_select_missing_pairing(monkeypatch): + class Missing(Exception): + pass + + monkeypatch.setattr("cli.commands.pair._ensure_path", lambda: None) + monkeypatch.setattr("cli.web_auth.PairingMissingError", Missing) + monkeypatch.setattr("cli.web_auth.select_pairing_address", lambda wallet: (_ for _ in ()).throw(Missing("missing"))) + + result = runner.invoke(pair_app, ["select", "0"]) + + assert result.exit_code == 1 + assert "missing" in result.output + + +def test_pair_open_builds_agent_wallet_url(monkeypatch): + calls = [] + monkeypatch.setattr("cli.commands.pair._ensure_path", lambda: None) + monkeypatch.setattr( + "cli.web_auth.open_wallet_ui", + lambda **kwargs: calls.append(kwargs) or "https://web-auth.example/?view=agent-wallets", + ) + + result = runner.invoke( + pair_app, + [ + "open", + "--account-id", + "acct", + "--agent-id", + "agent-cli-cost-e2e-maker", + "--agent-name", + "Maker", + "--include-pair-token", + "--no-browser", + ], + ) + + assert result.exit_code == 0 + assert calls == [ + { + "no_browser": True, + "account_id": "acct", + "agent_id": "agent-cli-cost-e2e-maker", + "agent_name": "Maker", + "include_pair_token": True, + } + ] + assert "web-auth" in result.output + + +def test_pair_bind_role_opens_and_persists_maker(monkeypatch): + calls = [] + class Pairing: + account_id = "acct" + + monkeypatch.setattr("cli.commands.pair._ensure_path", lambda: None) + monkeypatch.setattr("cli.web_auth.require_pairing", lambda: Pairing()) + monkeypatch.setattr( + "cli.web_auth.open_wallet_ui", + lambda **kwargs: calls.append(("open", kwargs)) or "https://web-auth.example/?view=agent-wallets", + ) + monkeypatch.setattr( + "cli.web_auth.wait_for_agent_wallet_binding", + lambda **kwargs: calls.append(("wait", kwargs)) or { + "walletAddress": "0x1111111111111111111111111111111111111111" + }, + ) + + result = runner.invoke(pair_app, ["bind-role", "maker", "--timeout", "1", "--no-browser"]) + + assert result.exit_code == 0 + assert calls[0][0] == "open" + assert calls[0][1]["account_id"] == "acct" + assert calls[0][1]["agent_id"] == "agent-cli-cost-e2e-maker" + assert calls[0][1]["include_pair_token"] is True + assert calls[1][0] == "wait" + assert calls[1][1]["role"] == "maker" + assert "Bound maker" in result.output + + +def test_pair_pending_lists_scoped_requests(monkeypatch): + monkeypatch.setattr("cli.commands.pair._ensure_path", lambda: None) + monkeypatch.setattr( + "cli.web_auth.fetch_pending_scoped_requests", + lambda: [ + { + "request_id": "req-1", + "summary": "tiny testnet order", + "requested_signer": "0x1111111111111111111111111111111111111111", + "programmatic_eligible": True, + } + ], + ) + + result = runner.invoke(pair_app, ["pending"]) + + assert result.exit_code == 0 + assert "req-1" in result.output + assert "eligible" in result.output + + +def test_pair_approve_calls_backend_with_yes(monkeypatch): + calls = [] + monkeypatch.setattr("cli.commands.pair._ensure_path", lambda: None) + monkeypatch.setattr( + "cli.web_auth.approve_scoped_request", + lambda request_id, approval="approve": calls.append((request_id, approval)) or { + "ok": True, + "approval": {"signer": "0x1111111111111111111111111111111111111111"}, + }, + ) + + result = runner.invoke(pair_app, ["approve", "req-1", "--yes"]) + + assert result.exit_code == 0 + assert calls == [("req-1", "approve")] + assert "Approved scoped request req-1" in result.output + + +def test_money_withdraw_requires_yes_in_non_interactive(monkeypatch): + class Request: + summary = "Withdraw 5 USDC" + + monkeypatch.setattr("cli.commands.money._ensure_path", lambda: None) + monkeypatch.setattr("cli.hl_actions.build_withdraw", lambda amount, destination, mainnet: Request()) + monkeypatch.setattr("cli.commands.money._submit", lambda request, mainnet: None) + + result = runner.invoke( + money_app, + ["withdraw", "5", "0x1111111111111111111111111111111111111111"], + ) + + assert result.exit_code == 1 + assert "Refusing to move funds without --yes" in result.output + + +def test_money_deposit_requires_yes_before_pairing(monkeypatch): + def fail_build(*args, **kwargs): + raise AssertionError("deposit should refuse before building transaction") + + monkeypatch.setattr("cli.commands.money._ensure_path", lambda: None) + monkeypatch.setattr("cli.hl_actions.build_deposit_transaction", fail_build) + + result = runner.invoke(money_app, ["deposit", "5"]) + + assert result.exit_code == 1 + assert "Refusing to move funds without --yes" in result.output + + +def test_money_bridge_is_deferred(): + result = runner.invoke(money_app, ["bridge"]) + + assert result.exit_code == 2 + assert "deferred" in result.output diff --git a/tests/test_strategy_claude_agent.py b/tests/test_strategy_claude_agent.py index 89b21d9..4d7e5e7 100644 --- a/tests/test_strategy_claude_agent.py +++ b/tests/test_strategy_claude_agent.py @@ -247,6 +247,84 @@ def test_hold_tool(self): assert "reasoning" in params["properties"] +class TestOpenRouterFusion: + def test_fusion_defaults_to_requested_route(self, monkeypatch): + from strategies.claude_agent import ClaudeStrategy + + monkeypatch.delenv("OPENROUTER_FUSION_MODEL", raising=False) + monkeypatch.delenv("NUNCHI_OPENROUTER_FUSION_MODEL", raising=False) + strat = ClaudeStrategy(model="openrouter/fusion") + + assert strat._resolve_openrouter_model() == "openrouter/fusion" + + def test_fusion_can_be_overridden(self, monkeypatch): + from strategies.claude_agent import ClaudeStrategy + + monkeypatch.setenv("OPENROUTER_FUSION_MODEL", "anthropic/claude-haiku") + strat = ClaudeStrategy(model="openrouter/fusion") + + assert strat._resolve_openrouter_model() == "anthropic/claude-haiku" + + def test_fusion_plugin_uses_budget_preset(self, monkeypatch): + from strategies.claude_agent import ClaudeStrategy + + monkeypatch.setenv("OPENROUTER_FUSION_PRESET", "general-budget") + strat = ClaudeStrategy(model="openrouter/fusion") + + assert strat._openrouter_fusion_plugins() == [ + {"id": "fusion", "preset": "general-budget"} + ] + + def test_fusion_plugin_supports_explicit_panel(self, monkeypatch): + from strategies.claude_agent import ClaudeStrategy + + monkeypatch.setenv( + "OPENROUTER_FUSION_ANALYSIS_MODELS", + "google/gemini-2.5-flash-lite, openai/gpt-5-nano", + ) + monkeypatch.setenv("OPENROUTER_FUSION_JUDGE_MODEL", "openai/gpt-5-nano") + monkeypatch.setenv("OPENROUTER_FUSION_MAX_TOOL_CALLS", "1") + strat = ClaudeStrategy(model="openrouter/fusion") + + assert strat._openrouter_fusion_plugins() == [ + { + "id": "fusion", + "analysis_models": [ + "google/gemini-2.5-flash-lite", + "openai/gpt-5-nano", + ], + "model": "openai/gpt-5-nano", + "max_tool_calls": 1, + } + ] + + def test_force_fusion_env(self, monkeypatch): + from strategies.claude_agent import ClaudeStrategy + + monkeypatch.setenv("OPENROUTER_FORCE_FUSION", "true") + strat = ClaudeStrategy(model="openrouter/fusion") + + assert strat._force_openrouter_fusion() is True + + def test_llm_decision_interval_skips_intermediate_ticks(self, monkeypatch): + from strategies.claude_agent import ClaudeStrategy + + monkeypatch.setenv("NUNCHI_LLM_DECISION_INTERVAL_TICKS", "3") + calls = [] + strat = ClaudeStrategy(model="gemini-2.0-flash") + + def fake_call(user_msg, snapshot): + calls.append(strat._current_tick_index) + return [] + + monkeypatch.setattr(strat, "_call_gemini", fake_call) + + for tick in range(1, 5): + strat.on_tick(_snap(), _ctx(round_num=tick)) + + assert calls == [1, 4] + + class TestClaudeStrategyOnTick: def test_zero_mid_returns_empty(self): from strategies.claude_agent import ClaudeStrategy diff --git a/tests/test_trade_command.py b/tests/test_trade_command.py new file mode 100644 index 0000000..421f753 --- /dev/null +++ b/tests/test_trade_command.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import typer +from typer.testing import CliRunner + +from cli.commands.trade import trade_cmd + + +class FakeConfig: + max_notional_usd = 100.0 + + def get_private_key(self) -> str: + return "0x" + "1" * 64 + + +class FakeRawHL: + def __init__(self, private_key: str, testnet: bool): + self.private_key = private_key + self.testnet = testnet + + +class FakeDirectHL: + placed = [] + + def __init__(self, raw_hl): + self.raw_hl = raw_hl + + def place_order(self, **kwargs): + self.placed.append(kwargs) + return None + + +def _app() -> typer.Typer: + app = typer.Typer() + app.command("trade")(trade_cmd) + return app + + +def _patch_trade_deps(monkeypatch): + import cli.commands.trade as trade_module + import cli.config as config_module + import cli.hl_adapter as hl_adapter_module + import cli.strategy_registry as strategy_registry_module + import parent.hl_proxy as hl_proxy_module + + FakeDirectHL.placed = [] + monkeypatch.setattr(config_module, "TradingConfig", FakeConfig) + monkeypatch.setattr(hl_adapter_module, "DirectHLProxy", FakeDirectHL) + monkeypatch.setattr(hl_proxy_module, "HLProxy", FakeRawHL) + monkeypatch.setattr(strategy_registry_module, "resolve_instrument", lambda instrument: instrument) + monkeypatch.setattr(trade_module.sys.stdin, "isatty", lambda: False) + + +def test_trade_dry_run_does_not_submit(monkeypatch): + _patch_trade_deps(monkeypatch) + runner = CliRunner() + + result = runner.invoke( + _app(), + ["ETH-PERP", "buy", "0.01", "--price", "2500", "--dry-run"], + ) + + assert result.exit_code == 0 + assert "Dry run: order not submitted." in result.output + assert FakeDirectHL.placed == [] + + +def test_trade_refuses_noninteractive_without_yes(monkeypatch): + _patch_trade_deps(monkeypatch) + runner = CliRunner() + + result = runner.invoke( + _app(), + ["ETH-PERP", "buy", "0.01", "--price", "2500"], + ) + + assert result.exit_code == 2 + assert "Refusing to trade non-interactively without --yes." in result.output + assert FakeDirectHL.placed == [] + + +def test_trade_rejects_notional_above_cap(monkeypatch): + _patch_trade_deps(monkeypatch) + runner = CliRunner() + + result = runner.invoke( + _app(), + [ + "ETH-PERP", + "buy", + "1", + "--price", + "2500", + "--yes", + "--max-notional", + "100", + ], + ) + + assert result.exit_code == 1 + assert "exceeds max notional" in result.output + assert FakeDirectHL.placed == []