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/README.md b/README.md
index f69ebcc..f4466e4 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
-
+
@@ -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/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/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/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/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/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 6253f07..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
@@ -35,6 +36,9 @@
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
+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)
@@ -46,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")
@@ -53,6 +58,9 @@
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")
+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..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,11 +22,28 @@ 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.")
+ mcp = FastMCP(
+ "yex-trader",
+ instructions=(
+ "Autonomous Hyperliquid trading CLI — 18 strategies, APEX orchestrator, "
+ "REFLECT reviews, and BTCSWP funding hedge proposals."
+ ),
+ )
# ------------------------------------------------------------------
# Fast tools — call Python directly (no subprocess overhead)
@@ -113,6 +131,7 @@ def setup_check() -> str:
issues = []
ok_items = []
+ warnings = []
# SDK
try:
@@ -124,12 +143,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 +174,85 @@ def setup_check() -> str:
return json.dumps({
"ok": ok_items,
+ "warnings": warnings,
"issues": issues,
"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",
+ 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)."""
@@ -168,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(
@@ -210,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/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/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/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/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}
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/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/pyproject.toml b/pyproject.toml
index 0b55b07..43e4b43 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,8 +33,9 @@ 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"]
[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/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/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/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 cb4a8ff..89c98f3 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
@@ -27,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")
@@ -108,6 +112,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"):
@@ -118,13 +124,20 @@ 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
# ---------------------------------------------------------------------------
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,8 +170,17 @@ def __init__(
self._anthropic_client = None
self._gemini_client = None
self._openai_client = None
+ 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
# ------------------------------------------------------------------
@@ -207,6 +229,265 @@ 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
+
+ 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
# ------------------------------------------------------------------
@@ -285,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)",
@@ -361,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,
@@ -425,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,
)
@@ -441,6 +756,147 @@ 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()
+ 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()
+
+ 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",
+ }
+ 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:
+ 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, input_tokens, output_tokens,
+ 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
+
+ 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)
# ------------------------------------------------------------------
@@ -493,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,
)
@@ -570,12 +1038,34 @@ 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)
+ elif provider == "openrouter":
+ decisions = self._call_openrouter(user_msg, snapshot)
elif provider == "gemini":
decisions = self._call_gemini(user_msg, snapshot)
elif provider == "claude":
@@ -586,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,
@@ -598,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/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_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_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_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_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"])
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 == []
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)