From d61e82c24f249a991e6608a045cab98b6744d2a1 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Fri, 26 Jun 2026 11:17:23 -0400 Subject: [PATCH 1/4] Add BTCSWP funding hedge CLI and MCP tools Co-authored-by: Cursor --- README.md | 24 +- cli/commands/hedge.py | 85 ++++++- cli/commands/setup.py | 20 ++ cli/mcp_server.py | 88 +++++++- cli/skill.md | 2 +- cli/strategy_registry.py | 2 +- modules/funding_hedge.py | 357 ++++++++++++++++++++++++++++++ strategies/hedge_agent.py | 7 +- tests/test_funding_hedge.py | 197 +++++++++++++++++ tests/test_setup_auth_guidance.py | 70 ++++++ 10 files changed, 841 insertions(+), 11 deletions(-) create mode 100644 modules/funding_hedge.py create mode 100644 tests/test_funding_hedge.py create mode 100644 tests/test_setup_auth_guidance.py diff --git a/README.md b/README.md index 5d87330..b4ffcc8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Strategies Tests License - MCP + MCP

@@ -75,6 +75,20 @@ hl run engine_mm -i ETH-PERP --tick 10 --mainnet hl apex run --mainnet ``` +### Funding Hedge + +Propose a read-only BTCSWP funding-rate hedge from the CLI or any MCP client. The default `hl hedge propose` path reads the current account position; passing `--perp-notional` switches to pure sizing mode with no account fetch or 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 ``` -**17 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`, `obsidian_context` +**19 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`, `obsidian_context` Fast tools (strategies, builder, wallet, setup, memory, journal, judge) call Python directly — zero subprocess overhead. @@ -618,7 +634,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 (19 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 index b0e42f3..a35a571 100644 --- a/cli/commands/hedge.py +++ b/cli/commands/hedge.py @@ -156,9 +156,53 @@ def _build_proposal(hl, coin: str): @hedge_app.command("propose") def propose_cmd( coin: str = typer.Argument("BTC", help="Coin to hedge (BTC, ETH)"), + asset: Optional[str] = typer.Option(None, "--asset", help="Alias for coin in pure sizing mode."), mainnet: bool = typer.Option(False, "--mainnet", help="Use mainnet (default: testnet)"), + side: str = typer.Option("long", "--side", help="Perp exposure side for pure sizing: long or short"), + perp_notional: Optional[float] = typer.Option( + None, + "--perp-notional", + help="Pure sizing mode: absolute perp notional in USD; does not fetch account state.", + ), + funding_apr: Optional[float] = typer.Option( + None, + "--funding-apr", + help="Pure sizing mode: annualized funding APR. Accepts 0.42 or 42 for 42%.", + ), + funding_rate_8h: Optional[float] = typer.Option( + None, + "--funding-rate-8h", + help="Pure sizing mode: 8h funding rate as a decimal, e.g. 0.0003.", + ), + 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 in pure sizing mode."), ): - """Show a CFI v2 hedge proposal without executing.""" + """Show a CFI v2 hedge proposal without executing. + + By default this reads the current account position. Passing + `--perp-notional` switches to pure sizing mode for agents/docs/tests. + """ + if perp_notional is not None: + from modules.funding_hedge import format_proposal, propose_funding_hedge + + try: + proposal = propose_funding_hedge( + asset=asset or coin, + 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)) + return + _boot_cli() from cli.config import TradingConfig @@ -379,8 +423,26 @@ def _refresh(): @hedge_app.command("backtest") def backtest_cmd( coin: str = typer.Option("BTC", "--coin", help="Coin (BTC or ETH)"), + asset: Optional[str] = typer.Option(None, "--asset", help="Alias for --coin in --csv mode."), days: int = typer.Option(365, "--days", help="Backtest window"), notional: float = typer.Option(1_000_000, "--notional", "-n"), + csv_path: Optional[Path] = typer.Option( + None, + "--csv", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Pure local cashflow mode: CSV with funding_rate_8h/funding_rate and optional hedge_rate_8h.", + ), + side: str = typer.Option("long", "--side", help="Perp exposure side for --csv mode: long or short"), + perp_notional: Optional[float] = typer.Option( + None, + "--perp-notional", + help="Pure --csv mode: absolute perp notional in USD; overrides --notional.", + ), + vol_multiplier: float = typer.Option(15.0, "--vol-multiplier", help="BTCSWP hedge multiplier for --csv mode."), + json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON in --csv mode."), script: Optional[Path] = typer.Option( None, "--script", @@ -391,7 +453,28 @@ def backtest_cmd( Shells out to `~/hyperliquid-funding-rate-perps/tools/hedge_calculator.py --backtest --asset {COIN} --notional {N}`. Output is streamed through. + Passing `--csv` switches to pure local cashflow mode. """ + if csv_path is not None: + from modules.funding_hedge import backtest_funding_hedge_csv, format_backtest + + try: + backtest = backtest_funding_hedge_csv( + csv_path=csv_path, + asset=asset or coin, + perp_side=side, + perp_notional_usd=perp_notional if perp_notional is not None else 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)) + return + _boot_cli() script_path = script or ( diff --git a/cli/commands/setup.py b/cli/commands/setup.py index 0e53dfa..4eb788b 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 pairing_from_env has_keystore = len(list_keystores()) > 0 + pairing = pairing_from_env() 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,13 @@ 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"web-auth pairing context provided ({pairing.address})") + else: + warnings.append( + "No web-auth pairing context found. Hosted/keyless signing uses " + "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS." + ) # 3. Network testnet = os.environ.get("HL_TESTNET", "true").lower() @@ -86,6 +101,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/mcp_server.py b/cli/mcp_server.py index f59d31d..81e15a7 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -29,7 +29,7 @@ "strategies", "builder_status", "wallet_list", "setup_check", "account", "status", "apex_status", "agent_memory", "trade_journal", "judge_report", "obsidian_context", - "order_status", "funding_rates", + "order_status", "funding_rates", "funding_hedge_propose", "funding_hedge_backtest", } # Tools that move funds or cancel/close live orders/positions — handle with care. _DESTRUCTIVE_TOOLS = { @@ -356,7 +356,7 @@ def _ann(name: str, title: str): "yex-trader", instructions=( "Autonomous Hyperliquid trading CLI — 14 strategies, APEX orchestrator, " - "REFLECT reviews. Always confirm details with the user before calling " + "REFLECT reviews, BTCSWP funding hedge proposals. Always confirm details with the user before calling " "destructive tools (trade, run_strategy, apex_run, schedule_cancel, " "emergency_close_all). " "emergency_close_all requires confirm=true." @@ -472,6 +472,7 @@ def setup_check(ctx: FastMCPContext = None) -> str: env_overrides = _request_env(ctx) issues = [] ok_items = [] + warnings = [] # SDK try: @@ -486,8 +487,14 @@ def setup_check(ctx: FastMCPContext = None) -> str: env_overrides.get("NUNCHI_WEB_AUTH_ADDRESS") ) keystores = list_keystores() + from cli.web_auth import pairing_from_env + pairing = pairing_from_env() if has_env_key: ok_items.append("HL_PRIVATE_KEY set") + if pairing is None and not has_web_auth: + warnings.append( + "Raw-key mode active. Prefer hl pair connect or hosted Nunchi Auth for MCP/agent use." + ) elif has_web_auth: ok_items.append("web-auth pairing context provided") elif keystores: @@ -497,6 +504,13 @@ def setup_check(ctx: FastMCPContext = None) -> str: "No signing context: set HL_PRIVATE_KEY, configure keystore, " "or pass trusted web-auth pairing context" ) + if pairing is not None: + ok_items.append(f"web-auth pairing context provided ({pairing.address})") + elif not has_web_auth: + warnings.append( + "No web-auth pairing context found. Hosted/keyless signing uses " + "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS." + ) # Network testnet = os.environ.get("HL_TESTNET", "true").lower() @@ -512,10 +526,80 @@ def setup_check(ctx: FastMCPContext = None) -> str: return json.dumps({ "ok": ok_items, + "warnings": warnings, "issues": issues, "passed": len(issues) == 0, }, indent=2) + @mcp.tool(**_ann("funding_hedge_propose", "Funding hedge proposal")) + 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(**_ann("funding_hedge_backtest", "Funding hedge backtest")) + 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(**_ann("account", "Account state")) def account(mainnet: bool = False, ctx: FastMCPContext = None) -> str: """Get Hyperliquid account state (balances, positions).""" diff --git a/cli/skill.md b/cli/skill.md index 9cd0388..0b7b574 100644 --- a/cli/skill.md +++ b/cli/skill.md @@ -203,7 +203,7 @@ Tools: `strategies`, `builder_status`, `wallet_list`, `wallet_auto`, `setup_chec | mean_reversion | Signal | Trades when price deviates from SMA | | momentum_breakout | Signal | Enters on volume + price breakout above/below N-period range | | aggressive_taker | Taker | Directional spread crossing with bias | -| hedge_agent | Risk | Reduces excess exposure per deterministic mandate | +| hedge_agent | Risk | Inventory exposure reducer; BTCSWP funding hedge lives under `hl hedge` | | rfq_agent | RFQ | Block-size dark RFQ liquidity | | claude_agent | LLM | Claude/Gemini-powered autonomous trading agent | diff --git a/cli/strategy_registry.py b/cli/strategy_registry.py index 61ea3eb..b7c29be 100644 --- a/cli/strategy_registry.py +++ b/cli/strategy_registry.py @@ -21,7 +21,7 @@ }, "hedge_agent": { "path": "strategies.hedge_agent:HedgeAgent", - "description": "inventory reducer (delta control)", + "description": "inventory reducer (delta control), not the BTCSWP funding-rate hedge", "params": {"notional_threshold": 15000.0}, }, "cfi_hedge": { diff --git a/modules/funding_hedge.py b/modules/funding_hedge.py new file mode 100644 index 0000000..93feff1 --- /dev/null +++ b/modules/funding_hedge.py @@ -0,0 +1,357 @@ +"""Pure-math funding-rate hedge proposal helpers. + +This module intentionally does not talk to Hyperliquid or sign orders. It gives +agents a deterministic way to size the public BTCSWP hedge slice. +""" +from __future__ import annotations + +import csv +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable, Literal, Optional + + +Side = Literal["long", "short"] + +BTCSWP_PROFILE = { + "asset": "BTC", + "hedge_market": "BTCSWP-USDYP", + "hl_coin": "yex:BTCSWP", + "vol_multiplier": 15.0, + "status": "deployed", +} + + +@dataclass(frozen=True) +class FundingHedgeProposal: + asset: str + perp_side: Side + perp_notional_usd: float + funding_apr: float + funding_rate_8h: Optional[float] + hedge_market: str + hedge_hl_coin: str + hedge_side: Side + hedge_notional_usd: float + vol_multiplier: float + effective_hedged_notional_usd: float + coverage_pct: float + unhedged_funding_cashflow_usd_per_year: float + target_hedge_cashflow_usd_per_year: float + assumption: str + status: str + disclaimer: str + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +@dataclass(frozen=True) +class FundingHedgeBacktestRow: + index: int + timestamp: Optional[str] + funding_rate_8h: float + hedge_rate_8h: float + unhedged_cashflow_usd: float + hedge_cashflow_usd: float + net_cashflow_usd: float + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +@dataclass(frozen=True) +class FundingHedgeBacktest: + asset: str + perp_side: Side + perp_notional_usd: float + hedge_market: str + hedge_hl_coin: str + hedge_side: Side + hedge_notional_usd: float + vol_multiplier: float + effective_hedged_notional_usd: float + coverage_pct: float + periods: int + average_funding_rate_8h: float + annualized_average_funding_apr: float + unhedged_cashflow_usd: float + hedge_cashflow_usd: float + net_cashflow_usd: float + max_period_unhedged_payment_usd: float + max_period_net_cost_usd: float + rows: list[FundingHedgeBacktestRow] + assumption: str + disclaimer: str + + def to_dict(self) -> dict[str, object]: + payload = asdict(self) + payload["rows"] = [row.to_dict() for row in self.rows] + return payload + + +def normalize_side(side: str) -> Side: + normalized = side.strip().lower() + if normalized not in {"long", "short"}: + raise ValueError("side must be 'long' or 'short'") + return normalized # type: ignore[return-value] + + +def normalize_apr(value: float) -> float: + """Accept either decimal APR (0.42) or percent APR (42).""" + if abs(value) > 1: + return value / 100.0 + return value + + +def annualize_funding_rate_8h(rate: float) -> float: + """Convert an 8h funding rate into simple annualized APR.""" + return rate * 3 * 365 + + +def _normalize_rate(value: float) -> float: + """Accept decimals, or whole percent values when clearly percent-like.""" + if abs(value) > 1: + return value / 100.0 + return value + + +def propose_funding_hedge( + *, + asset: str = "BTC", + perp_side: str = "long", + perp_notional_usd: float, + funding_apr: Optional[float] = None, + funding_rate_8h: Optional[float] = None, + vol_multiplier: float = BTCSWP_PROFILE["vol_multiplier"], +) -> FundingHedgeProposal: + """Size a BTCSWP hedge for a BTC perp funding exposure. + + Positive funding means longs pay shorts. The BTCSWP hedge is same-side and + sized at 1 / vol_multiplier notional so the rate leg targets the full perp + notional. + """ + asset = asset.strip().upper() + if asset != "BTC": + raise ValueError("only BTC funding hedges are deployed today; ETH/HYPE/SPCX profiles are roadmap") + if perp_notional_usd <= 0: + raise ValueError("perp_notional_usd must be positive") + if vol_multiplier <= 0: + raise ValueError("vol_multiplier must be positive") + if funding_apr is None and funding_rate_8h is None: + raise ValueError("provide funding_apr or funding_rate_8h") + + side = normalize_side(perp_side) + apr = annualize_funding_rate_8h(funding_rate_8h) if funding_apr is None else normalize_apr(funding_apr) + side_sign = 1 if side == "long" else -1 + + hedge_notional = perp_notional_usd / vol_multiplier + effective_notional = hedge_notional * vol_multiplier + unhedged_cashflow = -side_sign * perp_notional_usd * apr + target_hedge_cashflow = -unhedged_cashflow + + return FundingHedgeProposal( + asset=asset, + perp_side=side, + perp_notional_usd=round(perp_notional_usd, 2), + funding_apr=apr, + funding_rate_8h=funding_rate_8h, + hedge_market=BTCSWP_PROFILE["hedge_market"], + hedge_hl_coin=BTCSWP_PROFILE["hl_coin"], + hedge_side=side, + hedge_notional_usd=round(hedge_notional, 2), + vol_multiplier=vol_multiplier, + effective_hedged_notional_usd=round(effective_notional, 2), + coverage_pct=round(effective_notional / perp_notional_usd * 100, 4), + unhedged_funding_cashflow_usd_per_year=round(unhedged_cashflow, 2), + target_hedge_cashflow_usd_per_year=round(target_hedge_cashflow, 2), + assumption=( + "BTCSWP hedge is same-side and uses 1/15 notional by default; " + "positive funding means long perps pay shorts." + ), + status=BTCSWP_PROFILE["status"], + disclaimer="Sizing proposal only. This command does not place orders or expose the private rate methodology.", + ) + + +def _first_present(row: dict[str, str], names: Iterable[str]) -> Optional[str]: + for name in names: + value = row.get(name) + if value not in (None, ""): + return value + return None + + +def load_funding_rows_from_csv(path: str | Path) -> list[dict[str, Optional[str] | float]]: + """Load funding rows from CSV. + + Required column aliases: funding_rate_8h, perp_funding_rate_8h, funding_rate, or rate. + Optional hedge aliases: hedge_rate_8h, btcswp_rate_8h, btcswp_funding_rate_8h. + """ + csv_path = Path(path) + rows: list[dict[str, Optional[str] | float]] = [] + with csv_path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + for index, raw in enumerate(reader, start=1): + normalized = {(key or "").strip().lower(): (value or "").strip() for key, value in raw.items()} + funding_raw = _first_present( + normalized, + ("funding_rate_8h", "perp_funding_rate_8h", "funding_rate", "rate"), + ) + if funding_raw is None: + raise ValueError( + "CSV must include funding_rate_8h, perp_funding_rate_8h, funding_rate, or rate" + ) + hedge_raw = _first_present( + normalized, + ("hedge_rate_8h", "btcswp_rate_8h", "btcswp_funding_rate_8h"), + ) + try: + funding_rate = _normalize_rate(float(funding_raw)) + hedge_rate = _normalize_rate(float(hedge_raw)) if hedge_raw is not None else funding_rate + except ValueError as exc: + raise ValueError(f"invalid funding rate on CSV row {index}") from exc + rows.append( + { + "timestamp": _first_present(normalized, ("timestamp", "time", "date")), + "funding_rate_8h": funding_rate, + "hedge_rate_8h": hedge_rate, + } + ) + if not rows: + raise ValueError("CSV contains no funding rows") + return rows + + +def backtest_funding_hedge( + *, + funding_rows: list[dict[str, Optional[str] | float]], + asset: str = "BTC", + perp_side: str = "long", + perp_notional_usd: float, + vol_multiplier: float = BTCSWP_PROFILE["vol_multiplier"], +) -> FundingHedgeBacktest: + """Backtest funding cashflows for a same-side BTCSWP hedge.""" + proposal = propose_funding_hedge( + asset=asset, + perp_side=perp_side, + perp_notional_usd=perp_notional_usd, + funding_rate_8h=float(funding_rows[0]["funding_rate_8h"]), + vol_multiplier=vol_multiplier, + ) + side_sign = 1 if proposal.perp_side == "long" else -1 + + detail_rows: list[FundingHedgeBacktestRow] = [] + for index, row in enumerate(funding_rows, start=1): + funding_rate = float(row["funding_rate_8h"]) + hedge_rate = float(row["hedge_rate_8h"]) + unhedged = -side_sign * perp_notional_usd * funding_rate + hedge = side_sign * proposal.effective_hedged_notional_usd * hedge_rate + net = unhedged + hedge + detail_rows.append( + FundingHedgeBacktestRow( + index=index, + timestamp=str(row["timestamp"]) if row.get("timestamp") else None, + funding_rate_8h=funding_rate, + hedge_rate_8h=hedge_rate, + unhedged_cashflow_usd=round(unhedged, 2), + hedge_cashflow_usd=round(hedge, 2), + net_cashflow_usd=round(net, 2), + ) + ) + + periods = len(detail_rows) + avg_rate = sum(row.funding_rate_8h for row in detail_rows) / periods + unhedged_total = sum(row.unhedged_cashflow_usd for row in detail_rows) + hedge_total = sum(row.hedge_cashflow_usd for row in detail_rows) + net_total = sum(row.net_cashflow_usd for row in detail_rows) + max_unhedged_payment = max(max(-row.unhedged_cashflow_usd, 0.0) for row in detail_rows) + max_net_cost = max(max(-row.net_cashflow_usd, 0.0) for row in detail_rows) + + return FundingHedgeBacktest( + asset=proposal.asset, + perp_side=proposal.perp_side, + perp_notional_usd=proposal.perp_notional_usd, + hedge_market=proposal.hedge_market, + hedge_hl_coin=proposal.hedge_hl_coin, + hedge_side=proposal.hedge_side, + hedge_notional_usd=proposal.hedge_notional_usd, + vol_multiplier=proposal.vol_multiplier, + effective_hedged_notional_usd=proposal.effective_hedged_notional_usd, + coverage_pct=proposal.coverage_pct, + periods=periods, + average_funding_rate_8h=round(avg_rate, 10), + annualized_average_funding_apr=round(annualize_funding_rate_8h(avg_rate), 6), + unhedged_cashflow_usd=round(unhedged_total, 2), + hedge_cashflow_usd=round(hedge_total, 2), + net_cashflow_usd=round(net_total, 2), + max_period_unhedged_payment_usd=round(max_unhedged_payment, 2), + max_period_net_cost_usd=round(max_net_cost, 2), + rows=detail_rows, + assumption=( + "If no hedge_rate_8h/BTCSWP column is supplied, the backtest assumes " + "the BTCSWP hedge rate equals the perp funding rate for an idealized offset." + ), + disclaimer="Backtest is local cashflow math only. It does not place orders or model liquidity, fees, or mark-to-market.", + ) + + +def backtest_funding_hedge_csv( + *, + csv_path: str | Path, + asset: str = "BTC", + perp_side: str = "long", + perp_notional_usd: float, + vol_multiplier: float = BTCSWP_PROFILE["vol_multiplier"], +) -> FundingHedgeBacktest: + return backtest_funding_hedge( + funding_rows=load_funding_rows_from_csv(csv_path), + asset=asset, + perp_side=perp_side, + perp_notional_usd=perp_notional_usd, + vol_multiplier=vol_multiplier, + ) + + +def format_proposal(proposal: FundingHedgeProposal) -> str: + direction = "paying" if proposal.unhedged_funding_cashflow_usd_per_year < 0 else "receiving" + return "\n".join( + [ + "Funding Hedge Proposal", + "=" * 40, + f"Exposure: {proposal.perp_side.upper()} {proposal.asset} perp ${proposal.perp_notional_usd:,.2f}", + f"Funding APR: {proposal.funding_apr * 100:,.2f}%", + f"Unhedged leg: {direction} ${abs(proposal.unhedged_funding_cashflow_usd_per_year):,.2f}/yr", + "", + f"Hedge market: {proposal.hedge_market} ({proposal.hedge_hl_coin})", + f"Hedge action: {proposal.hedge_side.upper()} ${proposal.hedge_notional_usd:,.2f}", + f"Multiplier: {proposal.vol_multiplier:,.2f}x", + f"Coverage: ${proposal.effective_hedged_notional_usd:,.2f} ({proposal.coverage_pct:.2f}%)", + f"Target offset: ${proposal.target_hedge_cashflow_usd_per_year:,.2f}/yr", + "", + f"Assumption: {proposal.assumption}", + f"Status: {proposal.status}", + f"Disclaimer: {proposal.disclaimer}", + ] + ) + + +def format_backtest(backtest: FundingHedgeBacktest) -> str: + return "\n".join( + [ + "Funding Hedge Backtest", + "=" * 40, + f"Exposure: {backtest.perp_side.upper()} {backtest.asset} perp ${backtest.perp_notional_usd:,.2f}", + f"Hedge: {backtest.hedge_side.upper()} ${backtest.hedge_notional_usd:,.2f} {backtest.hedge_market}", + f"Periods: {backtest.periods}", + f"Avg funding APR: {backtest.annualized_average_funding_apr * 100:,.2f}%", + "", + f"Unhedged cashflow:{backtest.unhedged_cashflow_usd:>15,.2f} USD", + f"Hedge cashflow: {backtest.hedge_cashflow_usd:>15,.2f} USD", + f"Net cashflow: {backtest.net_cashflow_usd:>15,.2f} USD", + f"Max net cost: {backtest.max_period_net_cost_usd:>15,.2f} USD / period", + "", + f"Assumption: {backtest.assumption}", + f"Disclaimer: {backtest.disclaimer}", + ] + ) diff --git a/strategies/hedge_agent.py b/strategies/hedge_agent.py index 5068ad4..2d13d36 100644 --- a/strategies/hedge_agent.py +++ b/strategies/hedge_agent.py @@ -1,8 +1,11 @@ -"""Hedge agent — reduces excess exposure per deterministic mandate. +"""Hedge agent — reduces inventory exposure per deterministic mandate. From KorAI spec: "reduces exposure per deterministic mandate." Only acts when |inventory| exceeds a configurable threshold, then places aggressive orders to bring inventory back toward zero. + +This is not the BTCSWP funding-rate hedge. Use `hl hedge propose` or +`hl hedge backtest` for the public funding hedge tooling. """ from __future__ import annotations @@ -13,7 +16,7 @@ class HedgeAgent(BaseStrategy): - """Deterministic hedge agent that reduces inventory when overexposed.""" + """Deterministic inventory hedge agent that reduces overexposure.""" def __init__( self, diff --git a/tests/test_funding_hedge.py b/tests/test_funding_hedge.py new file mode 100644 index 0000000..1b1fdf9 --- /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, *args, **kwargs): + def decorator(fn): + self.tools[fn.__name__] = fn + return fn + + return decorator + + +def install_fake_mcp(monkeypatch) -> None: + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + +def test_propose_btcswp_funding_hedge_percent_apr(): + proposal = propose_funding_hedge( + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + funding_apr=42, + ) + + assert proposal.hedge_market == "BTCSWP-USDYP" + assert proposal.hedge_side == "long" + assert proposal.hedge_notional_usd == 10_000 + assert proposal.effective_hedged_notional_usd == 150_000 + assert proposal.funding_apr == 0.42 + assert proposal.unhedged_funding_cashflow_usd_per_year == -63_000 + assert proposal.target_hedge_cashflow_usd_per_year == 63_000 + + +def test_propose_annualizes_8h_funding_rate(): + apr = annualize_funding_rate_8h(0.0003) + proposal = propose_funding_hedge( + asset="BTC", + perp_side="short", + perp_notional_usd=90_000, + funding_rate_8h=0.0003, + ) + + assert proposal.funding_apr == apr + assert proposal.hedge_notional_usd == 6_000 + assert proposal.unhedged_funding_cashflow_usd_per_year == 29_565 + + +def test_hedge_propose_cli_json(): + result = runner.invoke( + app, + ["hedge", "propose", "--perp-notional", "150000", "--side", "long", "--funding-apr", "42", "--json"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["hedge_market"] == "BTCSWP-USDYP" + assert payload["hedge_notional_usd"] == 10_000 + assert payload["disclaimer"].startswith("Sizing proposal only.") + + +def test_mcp_funding_hedge_propose(monkeypatch): + install_fake_mcp(monkeypatch) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads( + server.tools["funding_hedge_propose"]( + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + funding_apr=42, + ) + ) + + assert payload["hedge_market"] == "BTCSWP-USDYP" + assert payload["hedge_side"] == "long" + assert payload["hedge_notional_usd"] == 10_000 + assert payload["coverage_pct"] == 100 + + +def test_mcp_funding_hedge_rejects_roadmap_assets(monkeypatch): + install_fake_mcp(monkeypatch) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads(server.tools["funding_hedge_propose"](asset="ETH", funding_apr=10)) + + assert "only BTC funding hedges are deployed today" in payload["error"] + + +def test_backtest_csv_idealized_offset(tmp_path): + csv_path = tmp_path / "funding.csv" + csv_path.write_text("timestamp,funding_rate_8h\n1,0.0003\n2,-0.0001\n", "utf-8") + + backtest = backtest_funding_hedge_csv( + csv_path=csv_path, + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + ) + + assert backtest.periods == 2 + assert backtest.hedge_notional_usd == 10_000 + assert backtest.unhedged_cashflow_usd == -30 + assert backtest.hedge_cashflow_usd == 30 + assert backtest.net_cashflow_usd == 0 + + +def test_backtest_csv_realized_hedge_residual(tmp_path): + csv_path = tmp_path / "funding.csv" + csv_path.write_text("date,funding_rate_8h,btcswp_rate_8h\n2026-01-01,0.0003,0.00025\n", "utf-8") + + backtest = backtest_funding_hedge_csv( + csv_path=csv_path, + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + ) + + assert backtest.unhedged_cashflow_usd == -45 + assert backtest.hedge_cashflow_usd == 37.5 + assert backtest.net_cashflow_usd == -7.5 + assert backtest.max_period_net_cost_usd == 7.5 + + +def test_hedge_backtest_cli_json(tmp_path): + csv_path = tmp_path / "funding.csv" + csv_path.write_text("funding_rate\n0.0003\n-0.0001\n", "utf-8") + + result = runner.invoke( + app, + [ + "hedge", + "backtest", + "--csv", + str(csv_path), + "--perp-notional", + "150000", + "--side", + "long", + "--json", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["periods"] == 2 + assert payload["hedge_market"] == "BTCSWP-USDYP" + assert payload["net_cashflow_usd"] == 0 + + +def test_mcp_funding_hedge_backtest(monkeypatch, tmp_path): + csv_path = tmp_path / "funding.csv" + csv_path.write_text("funding_rate_8h\n0.0003\n", "utf-8") + install_fake_mcp(monkeypatch) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads( + server.tools["funding_hedge_backtest"]( + csv_path=str(csv_path), + asset="BTC", + perp_side="long", + perp_notional_usd=150_000, + ) + ) + + assert payload["periods"] == 1 + assert payload["unhedged_cashflow_usd"] == -45 + assert payload["hedge_cashflow_usd"] == 45 diff --git a/tests/test_setup_auth_guidance.py b/tests/test_setup_auth_guidance.py new file mode 100644 index 0000000..9ad8bd9 --- /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, *args, **kwargs): + 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.pairing_from_env", 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 "NUNCHI_WEB_AUTH_PAIR_TOKEN" 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 web-auth pairing context found" in warning for warning in payload["warnings"]) From 70090460725a0a9f361d373219d3355aa41817a0 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Fri, 26 Jun 2026 13:52:21 -0400 Subject: [PATCH 2/4] Add funding hedge capability discovery Co-authored-by: Cursor --- README.md | 10 +++--- cli/commands/hedge.py | 17 ++++++++++ cli/mcp_server.py | 9 +++++- modules/funding_hedge.py | 64 +++++++++++++++++++++++++++++++++++++ tests/test_funding_hedge.py | 38 +++++++++++++++++++++- 5 files changed, 132 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b4ffcc8..24a2f95 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Strategies Tests License - MCP + MCP

@@ -80,6 +80,7 @@ hl apex run --mainnet Propose a read-only BTCSWP funding-rate hedge from the CLI or any MCP client. The default `hl hedge propose` path reads the current account position; passing `--perp-notional` switches to pure sizing mode with no account fetch or order execution. ```bash +hl hedge info --json 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 @@ -87,7 +88,7 @@ hl hedge backtest --csv funding.csv --asset BTC --side long --perp-notional 1500 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` +MCP tools: `funding_hedge_info`, `funding_hedge_propose`, `funding_hedge_backtest` --- @@ -474,6 +475,7 @@ 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 info [--json] # Funding hedge profiles and schemas hl hedge propose [options] # BTCSWP funding hedge proposal hl hedge backtest --csv # Local funding hedge cashflow backtest @@ -497,7 +499,7 @@ hl mcp serve # stdio transport (default) hl mcp serve --transport sse # SSE transport ``` -**19 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`, `obsidian_context` +**20 tools exposed:** `account`, `status`, `trade`, `run_strategy`, `strategies`, `funding_hedge_info`, `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`, `obsidian_context` Fast tools (strategies, builder, wallet, setup, memory, journal, judge) call Python directly — zero subprocess overhead. @@ -634,7 +636,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 (19 tools via FastMCP) + mcp_server.py MCP server (20 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 index a35a571..2292e59 100644 --- a/cli/commands/hedge.py +++ b/cli/commands/hedge.py @@ -150,6 +150,23 @@ def _build_proposal(hl, coin: str): return proposal, snapshot +# ─── info ──────────────────────────────────────────────────────────────────── + + +@hedge_app.command("info") +def info_cmd( + json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON."), +): + """Show deployed funding hedge capabilities and agent-facing schemas.""" + from modules.funding_hedge import format_info, funding_hedge_info + + info = funding_hedge_info() + if json_output: + typer.echo(json.dumps(info, indent=2)) + else: + typer.echo(format_info(info)) + + # ─── propose ───────────────────────────────────────────────────────────────── diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 81e15a7..3f54491 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -29,7 +29,7 @@ "strategies", "builder_status", "wallet_list", "setup_check", "account", "status", "apex_status", "agent_memory", "trade_journal", "judge_report", "obsidian_context", - "order_status", "funding_rates", "funding_hedge_propose", "funding_hedge_backtest", + "order_status", "funding_rates", "funding_hedge_info", "funding_hedge_propose", "funding_hedge_backtest", } # Tools that move funds or cancel/close live orders/positions — handle with care. _DESTRUCTIVE_TOOLS = { @@ -531,6 +531,13 @@ def setup_check(ctx: FastMCPContext = None) -> str: "passed": len(issues) == 0, }, indent=2) + @mcp.tool(**_ann("funding_hedge_info", "Funding hedge info")) + def funding_hedge_info() -> str: + """Describe deployed funding hedge profiles and input schemas.""" + from modules.funding_hedge import funding_hedge_info as build_info + + return json.dumps(build_info(), indent=2) + @mcp.tool(**_ann("funding_hedge_propose", "Funding hedge proposal")) def funding_hedge_propose( asset: str = "BTC", diff --git a/modules/funding_hedge.py b/modules/funding_hedge.py index 93feff1..5cb0b31 100644 --- a/modules/funding_hedge.py +++ b/modules/funding_hedge.py @@ -21,6 +21,12 @@ "status": "deployed", } +ROADMAP_PROFILES = [ + {"asset": "ETH", "hedge_market": "ETHSWP-USDYP", "status": "roadmap"}, + {"asset": "HYPE", "hedge_market": "HYPESWP-USDYP", "status": "roadmap"}, + {"asset": "SPCX", "hedge_market": "SPCXSWP-USDYP", "status": "roadmap"}, +] + @dataclass(frozen=True) class FundingHedgeProposal: @@ -90,6 +96,36 @@ def to_dict(self) -> dict[str, object]: return payload +def funding_hedge_info() -> dict[str, object]: + """Return agent-discoverable metadata for the public hedge slice.""" + return { + "name": "BTCSWP funding-rate hedge", + "summary": ( + "Pure sizing and local cashflow backtesting for Nunchi's public BTC " + "funding-rate hedge surface." + ), + "deployed_profiles": [dict(BTCSWP_PROFILE)], + "roadmap_profiles": [dict(profile) for profile in ROADMAP_PROFILES], + "default_vol_multiplier": BTCSWP_PROFILE["vol_multiplier"], + "sizing_rule": "same-side BTCSWP, hedge_notional = perp_notional / vol_multiplier", + "supported_cli": [ + "hl hedge propose --perp-notional ... --funding-apr ...", + "hl hedge backtest --csv ... --perp-notional ...", + ], + "mcp_tools": ["funding_hedge_info", "funding_hedge_propose", "funding_hedge_backtest"], + "csv_required_columns": ["funding_rate_8h", "perp_funding_rate_8h", "funding_rate", "rate"], + "csv_optional_columns": ["hedge_rate_8h", "btcswp_rate_8h", "btcswp_funding_rate_8h"], + "hedge_agent_distinction": ( + "strategy hedge_agent is an inventory/delta reducer. The BTCSWP " + "funding-rate hedge lives under hl hedge and the funding_hedge_* MCP tools." + ), + "execution_boundary": ( + "funding_hedge_info/propose/backtest do not place orders, sign payloads, " + "fetch private account state, or expose private rate methodology." + ), + } + + def normalize_side(side: str) -> Side: normalized = side.strip().lower() if normalized not in {"long", "short"}: @@ -336,6 +372,34 @@ def format_proposal(proposal: FundingHedgeProposal) -> str: ) +def format_info(info: dict[str, object]) -> str: + profiles = info.get("deployed_profiles", []) + deployed = profiles[0] if isinstance(profiles, list) and profiles else {} + if not isinstance(deployed, dict): + deployed = {} + return "\n".join( + [ + "Funding Hedge Info", + "=" * 40, + f"Name: {info['name']}", + f"Summary: {info['summary']}", + f"Deployed: {deployed.get('asset', 'BTC')} -> {deployed.get('hedge_market', 'BTCSWP-USDYP')}", + f"Multiplier: {info['default_vol_multiplier']}x", + f"Sizing rule: {info['sizing_rule']}", + "", + "CLI:", + *[f" {cmd}" for cmd in info["supported_cli"]], # type: ignore[index] + "", + "MCP:", + *[f" {tool}" for tool in info["mcp_tools"]], # type: ignore[index] + "", + f"CSV required: {', '.join(info['csv_required_columns'])}", # type: ignore[arg-type] + f"CSV optional: {', '.join(info['csv_optional_columns'])}", # type: ignore[arg-type] + f"Note: {info['hedge_agent_distinction']}", + ] + ) + + def format_backtest(backtest: FundingHedgeBacktest) -> str: return "\n".join( [ diff --git a/tests/test_funding_hedge.py b/tests/test_funding_hedge.py index 1b1fdf9..aae315d 100644 --- a/tests/test_funding_hedge.py +++ b/tests/test_funding_hedge.py @@ -8,7 +8,12 @@ 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 +from modules.funding_hedge import ( + annualize_funding_rate_8h, + backtest_funding_hedge_csv, + funding_hedge_info, + propose_funding_hedge, +) runner = CliRunner() @@ -69,6 +74,16 @@ def test_propose_annualizes_8h_funding_rate(): assert proposal.unhedged_funding_cashflow_usd_per_year == 29_565 +def test_funding_hedge_info_describes_deployed_profile(): + info = funding_hedge_info() + + assert info["deployed_profiles"][0]["asset"] == "BTC" # type: ignore[index] + assert info["deployed_profiles"][0]["hedge_market"] == "BTCSWP-USDYP" # type: ignore[index] + assert "funding_hedge_info" in info["mcp_tools"] + assert "funding_rate_8h" in info["csv_required_columns"] + assert "hedge_agent is an inventory/delta reducer" in info["hedge_agent_distinction"] + + def test_hedge_propose_cli_json(): result = runner.invoke( app, @@ -82,6 +97,15 @@ def test_hedge_propose_cli_json(): assert payload["disclaimer"].startswith("Sizing proposal only.") +def test_hedge_info_cli_json(): + result = runner.invoke(app, ["hedge", "info", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["deployed_profiles"][0]["hedge_market"] == "BTCSWP-USDYP" + assert "funding_hedge_backtest" in payload["mcp_tools"] + + def test_mcp_funding_hedge_propose(monkeypatch): install_fake_mcp(monkeypatch) @@ -103,6 +127,18 @@ def test_mcp_funding_hedge_propose(monkeypatch): assert payload["coverage_pct"] == 100 +def test_mcp_funding_hedge_info(monkeypatch): + install_fake_mcp(monkeypatch) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads(server.tools["funding_hedge_info"]()) + + assert payload["deployed_profiles"][0]["asset"] == "BTC" + assert "funding_hedge_propose" in payload["mcp_tools"] + + def test_mcp_funding_hedge_rejects_roadmap_assets(monkeypatch): install_fake_mcp(monkeypatch) From 84b22fa48bb072f087462d8807d1d42f65ce89b6 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Sat, 27 Jun 2026 11:52:53 -0400 Subject: [PATCH 3/4] Add scoped-token auth and live hedge MCP execution Co-authored-by: Cursor --- README.md | 15 ++-- cli/commands/auth.py | 133 ++++++++++++++++++++++++++++++++ cli/commands/hedge.py | 13 ++++ cli/commands/setup.py | 6 +- cli/main.py | 2 + cli/mcp_server.py | 78 ++++++++++++++++--- cli/web_auth.py | 120 +++++++++++++++++++++++++++- modules/funding_hedge.py | 12 ++- tests/test_auth_scoped_token.py | 63 +++++++++++++++ tests/test_engine_strategies.py | 2 +- tests/test_funding_hedge.py | 58 ++++++++++++++ tests/test_hedge_margin_port.py | 45 +++++++++++ tests/test_mcp_annotations.py | 3 +- tests/test_web_auth_signer.py | 15 ++++ 14 files changed, 542 insertions(+), 23 deletions(-) create mode 100644 cli/commands/auth.py create mode 100644 tests/test_auth_scoped_token.py diff --git a/README.md b/README.md index 24a2f95..8a8472f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Strategies Tests License - MCP + MCP

@@ -77,18 +77,21 @@ hl apex run --mainnet ### Funding Hedge -Propose a read-only BTCSWP funding-rate hedge from the CLI or any MCP client. The default `hl hedge propose` path reads the current account position; passing `--perp-notional` switches to pure sizing mode with no account fetch or order execution. +Propose, backtest, or execute a BTCSWP funding-rate hedge from the CLI or any MCP client. The default `hl hedge propose` path reads the current account position; passing `--perp-notional` switches to pure sizing mode with no account fetch or order execution. ```bash hl hedge info --json +hl auth import --token --address 0x... --permission-tier testnet_trading 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 execute BTC --dry-run +hl hedge execute BTC --yes 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_info`, `funding_hedge_propose`, `funding_hedge_backtest` +MCP tools: `funding_hedge_info`, `funding_hedge_propose`, `funding_hedge_backtest`, `funding_hedge_execute`. Live MCP execution requires `confirmed=true` plus a signing context from `HL_PRIVATE_KEY`, keystore, trusted hosted context, or a local scoped token stored with `hl auth import`. --- @@ -477,9 +480,11 @@ hl guard run -i ETH-PERP [options] # Guard trailing stop hl reflect run [--since DATE] # Performance review hl hedge info [--json] # Funding hedge profiles and schemas hl hedge propose [options] # BTCSWP funding hedge proposal +hl hedge execute BTC [--dry-run] # Execute or preview BTCSWP hedge hl hedge backtest --csv # Local funding hedge cashflow backtest # Infrastructure +hl auth import/status/export-env # Local scoped-token keyless auth hl builder approve [--mainnet] # Approve builder fee hl wallet auto [--save-env] # Create wallet (agent-friendly) hl setup check # Validate environment @@ -499,7 +504,7 @@ hl mcp serve # stdio transport (default) hl mcp serve --transport sse # SSE transport ``` -**20 tools exposed:** `account`, `status`, `trade`, `run_strategy`, `strategies`, `funding_hedge_info`, `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`, `obsidian_context` +**21 tools exposed:** `account`, `status`, `trade`, `run_strategy`, `strategies`, `funding_hedge_info`, `funding_hedge_propose`, `funding_hedge_backtest`, `funding_hedge_execute`, `radar_run`, `apex_status`, `apex_run`, `reflect_run`, `setup_check`, `builder_status`, `wallet_list`, `wallet_auto`, `agent_memory`, `trade_journal`, `judge_report`, `obsidian_context` Fast tools (strategies, builder, wallet, setup, memory, journal, judge) call Python directly — zero subprocess overhead. @@ -636,7 +641,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 (20 tools via FastMCP) + mcp_server.py MCP server (21 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/auth.py b/cli/commands/auth.py new file mode 100644 index 0000000..b96c586 --- /dev/null +++ b/cli/commands/auth.py @@ -0,0 +1,133 @@ +"""hl auth — local scoped-token management for keyless agent flows.""" +from __future__ import annotations + +import json +import time +from typing import Optional + +import typer + +auth_app = typer.Typer(no_args_is_help=True) + + +def _redact(token: str) -> str: + if len(token) <= 12: + return token[:2] + "..." + return token[:6] + "..." + token[-4:] + + +@auth_app.command("import", help="Store a scoped Nunchi web-auth token locally") +def auth_import( + token: str = typer.Option(..., "--token", prompt=True, hide_input=True, help="Scoped web-auth token."), + address: str = typer.Option(..., "--address", help="Authorized wallet address."), + account_id: str = typer.Option("", "--account-id", help="Optional Nunchi account id."), + permission_tier: str = typer.Option( + "testnet_trading", + "--permission-tier", + help="read_only, testnet_trading, or live_trading.", + ), + network: str = typer.Option("testnet", "--network", help="testnet or mainnet."), + allow_mainnet: bool = typer.Option(False, "--allow-mainnet", help="Allow mainnet actions."), + max_order_size: Optional[float] = typer.Option(None, "--max-order-size", help="Optional max order size."), + max_hedge_notional: Optional[float] = typer.Option( + None, + "--max-hedge-notional", + help="Optional max BTCSWP hedge notional in USD.", + ), + max_strategy_ticks: Optional[int] = typer.Option(None, "--max-strategy-ticks", help="Optional max ticks."), + require_confirmation: bool = typer.Option( + True, + "--require-confirmation/--no-require-confirmation", + help="Require confirmed=true for hosted/MCP write tools.", + ), + json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON."), +) -> None: + """Persist a scoped token so local CLI/MCP can sign without raw private keys.""" + from cli.web_auth import ScopedToken, save_scoped_token + + tier = permission_tier.strip().lower() + if tier not in {"read_only", "testnet_trading", "live_trading"}: + raise typer.BadParameter("permission-tier must be read_only, testnet_trading, or live_trading") + net = network.strip().lower() + if net not in {"testnet", "mainnet"}: + raise typer.BadParameter("network must be testnet or mainnet") + + scoped = ScopedToken( + token=token.strip(), + address=address.strip(), + account_id=account_id.strip(), + permission_tier=tier, + network=net, + allow_mainnet=allow_mainnet, + max_order_size=max_order_size, + max_hedge_notional=max_hedge_notional, + max_strategy_ticks=max_strategy_ticks, + require_confirmation=require_confirmation, + created_at_ms=int(time.time() * 1000), + ) + path = save_scoped_token(scoped) + payload = { + "stored": True, + "path": str(path), + "address": scoped.address, + "permission_tier": scoped.permission_tier, + "network": scoped.network, + "allow_mainnet": scoped.allow_mainnet, + "token": _redact(scoped.token), + } + typer.echo(json.dumps(payload, indent=2) if json_output else f"Stored scoped token for {scoped.address} at {path}") + + +@auth_app.command("status", help="Show stored scoped-token status") +def auth_status(json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON.")) -> None: + from cli.web_auth import load_scoped_token, scoped_token_path + + scoped = load_scoped_token() + if scoped is None: + payload = {"configured": False, "path": str(scoped_token_path())} + else: + payload = { + "configured": True, + "path": str(scoped_token_path()), + "address": scoped.address, + "account_id": scoped.account_id, + "permission_tier": scoped.permission_tier, + "network": scoped.network, + "allow_mainnet": scoped.allow_mainnet, + "max_order_size": scoped.max_order_size, + "max_hedge_notional": scoped.max_hedge_notional, + "max_strategy_ticks": scoped.max_strategy_ticks, + "require_confirmation": scoped.require_confirmation, + "token": _redact(scoped.token), + } + if json_output: + typer.echo(json.dumps(payload, indent=2)) + elif not payload["configured"]: + typer.echo(f"No scoped token configured at {payload['path']}") + else: + typer.echo( + f"Scoped token active for {payload['address']} " + f"({payload['permission_tier']}, {payload['network']})" + ) + + +@auth_app.command("export-env", help="Print shell exports for the stored scoped token") +def auth_export_env() -> None: + from cli.web_auth import scoped_token_env + + env = scoped_token_env() + if not env: + typer.echo("No scoped token configured.", err=True) + raise typer.Exit(1) + for key, value in env.items(): + escaped = value.replace("'", "'\"'\"'") + typer.echo(f"export {key}='{escaped}'") + + +@auth_app.command("revoke", help="Delete the local scoped token") +def auth_revoke() -> None: + from cli.web_auth import clear_scoped_token, scoped_token_path + + path = scoped_token_path() + clear_scoped_token() + typer.echo(f"Removed local scoped token at {path}") diff --git a/cli/commands/hedge.py b/cli/commands/hedge.py index 2292e59..5515550 100644 --- a/cli/commands/hedge.py +++ b/cli/commands/hedge.py @@ -245,6 +245,11 @@ def execute_cmd( dry_run: bool = typer.Option(False, "--dry-run", help="Preview only; do not sign or submit"), yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirm"), mainnet: bool = typer.Option(False, "--mainnet", help="Use mainnet (default: testnet)"), + max_hedge_notional: Optional[float] = typer.Option( + None, + "--max-hedge-notional", + help="Reject execution if proposed BTCSWP hedge notional exceeds this USD cap.", + ), ): """Build the proposal and optionally sign + submit a real yex:{COIN}SWP order. @@ -266,6 +271,14 @@ def execute_cmd( proposal, snapshot = _build_proposal(hl, coin) typer.echo(hedge_proposal_block(proposal, snapshot, mainnet=mainnet)) + if max_hedge_notional is not None and proposal.hedge_notional_usd > max_hedge_notional: + typer.echo( + f"Refusing hedge: proposed notional ${proposal.hedge_notional_usd:,.2f} " + f"exceeds cap ${max_hedge_notional:,.2f}.", + err=True, + ) + raise typer.Exit(2) + # Size the order in CFI v2 (BTCSWP) units. SDK rounds to szDecimals. wire_px = snapshot.oracle_px or proposal.profile.baseline_b0 size = proposal.hedge_notional_usd / wire_px diff --git a/cli/commands/setup.py b/cli/commands/setup.py index 4eb788b..c77e474 100644 --- a/cli/commands/setup.py +++ b/cli/commands/setup.py @@ -38,7 +38,7 @@ def setup_check(): 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 " + "Raw-key mode active. For MCP/agent use, prefer `hl auth import` or hosted Nunchi Auth " "so the AI client receives scoped access instead of a private key." ) elif has_keystore: @@ -50,6 +50,8 @@ def setup_check(): ok_items.append("HL_KEYSTORE_PASSWORD found in ~/.hl-agent/env") else: issues.append("HL_KEYSTORE_PASSWORD not set (needed for auto-unlock)") + elif pairing is not None: + ok_items.append(f"scoped-token signing context found ({pairing.address})") else: issues.append("No private key: set HL_PRIVATE_KEY or run 'hl wallet import'") if pairing is not None: @@ -57,7 +59,7 @@ def setup_check(): else: warnings.append( "No web-auth pairing context found. Hosted/keyless signing uses " - "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS." + "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS, or run `hl auth import` locally." ) # 3. Network diff --git a/cli/main.py b/cli/main.py index a928b7a..e0b84a7 100644 --- a/cli/main.py +++ b/cli/main.py @@ -36,6 +36,7 @@ from cli.commands.journal import journal_app from cli.commands.keys import keys_app from cli.commands.hedge import hedge_app +from cli.commands.auth import auth_app from cli.commands.margin import margin_app from cli.commands.trading import trading_app from cli.commands.house import house_app @@ -66,6 +67,7 @@ app.add_typer(skills_app, name="skills", help="Skill discovery and registry") app.add_typer(journal_app, name="journal", help="Trade journal — structured position records with reasoning") app.add_typer(keys_app, name="keys", help="Unified key management across backends") +app.add_typer(auth_app, name="auth", help="Scoped-token auth for keyless local agents") app.add_typer(hedge_app, name="hedge", help="CFI v2 funding-rate hedge — propose, execute, status, backtest, auto") app.add_typer(margin_app, name="margin", help="HL collateral — deposits, sub-DEX transfers, isolated margin, auto-topup") app.add_typer(trading_app, name="trading", help="Trading data surfaces — joined JSON contracts for UIs/bridges") diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 3f54491..e57fa4a 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -34,6 +34,7 @@ # Tools that move funds or cancel/close live orders/positions — handle with care. _DESTRUCTIVE_TOOLS = { "trade", "run_strategy", "apex_run", "schedule_cancel", "emergency_close_all", + "funding_hedge_execute", } # Everything else (wallet_auto, radar_run, reflect_run) is # state-changing-but-safe: neither a pure read nor fund-destructive. @@ -61,6 +62,8 @@ "x-nunchi-secret-nunchi-allow-mainnet": "NUNCHI_ALLOW_MAINNET", "x-nunchi-max-order-size": "NUNCHI_MAX_ORDER_SIZE", "x-nunchi-secret-nunchi-max-order-size": "NUNCHI_MAX_ORDER_SIZE", + "x-nunchi-max-hedge-notional": "NUNCHI_MAX_HEDGE_NOTIONAL", + "x-nunchi-secret-nunchi-max-hedge-notional": "NUNCHI_MAX_HEDGE_NOTIONAL", "x-nunchi-max-strategy-ticks": "NUNCHI_MAX_STRATEGY_TICKS", "x-nunchi-secret-nunchi-max-strategy-ticks": "NUNCHI_MAX_STRATEGY_TICKS", "x-nunchi-require-confirmation": "NUNCHI_REQUIRE_CONFIRMATION", @@ -356,24 +359,32 @@ def _ann(name: str, title: str): "yex-trader", instructions=( "Autonomous Hyperliquid trading CLI — 14 strategies, APEX orchestrator, " - "REFLECT reviews, BTCSWP funding hedge proposals. Always confirm details with the user before calling " + "REFLECT reviews, BTCSWP funding hedge proposal and execution. Use `hl auth import` locally " + "or trusted Nunchi gateway context for scoped-token keyless signing. Always confirm details with the user before calling " "destructive tools (trade, run_strategy, apex_run, schedule_cancel, " - "emergency_close_all). " - "emergency_close_all requires confirm=true." + "emergency_close_all, funding_hedge_execute). " + "emergency_close_all requires confirm=true; funding_hedge_execute requires confirmed=true for live execution." ), ) def _request_env(ctx: Any = None) -> dict[str, str]: + def _local_scoped_env() -> dict[str, str]: + try: + from cli.web_auth import scoped_token_env + return scoped_token_env() + except Exception: + return {} + if ctx is not None: - return _trusted_context_env_overrides(ctx) + return _trusted_context_env_overrides(ctx) or _local_scoped_env() try: get_context = getattr(mcp, "get_context") except AttributeError: - return {} + return _local_scoped_env() try: - return _trusted_context_env_overrides(get_context()) + return _trusted_context_env_overrides(get_context()) or _local_scoped_env() except Exception: - return {} + return _local_scoped_env() # ------------------------------------------------------------------ # Fast tools — call Python directly (no subprocess overhead) @@ -493,7 +504,7 @@ def setup_check(ctx: FastMCPContext = None) -> str: ok_items.append("HL_PRIVATE_KEY set") if pairing is None and not has_web_auth: warnings.append( - "Raw-key mode active. Prefer hl pair connect or hosted Nunchi Auth for MCP/agent use." + "Raw-key mode active. Prefer hl auth import or hosted Nunchi Auth for MCP/agent use." ) elif has_web_auth: ok_items.append("web-auth pairing context provided") @@ -509,7 +520,7 @@ def setup_check(ctx: FastMCPContext = None) -> str: elif not has_web_auth: warnings.append( "No web-auth pairing context found. Hosted/keyless signing uses " - "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS." + "NUNCHI_WEB_AUTH_PAIR_TOKEN and NUNCHI_WEB_AUTH_ADDRESS, or hl auth import locally." ) # Network @@ -547,7 +558,7 @@ def funding_hedge_propose( funding_rate_8h: Optional[float] = None, vol_multiplier: float = 15.0, ) -> str: - """Propose a read-only BTCSWP funding-rate hedge. + """Propose a BTCSWP funding-rate hedge without placing orders. Args: asset: Underlying perp exposure. BTC is deployed today. @@ -607,6 +618,53 @@ def funding_hedge_backtest( return json.dumps({"error": str(exc)}, indent=2) return json.dumps(backtest.to_dict(), indent=2) + @mcp.tool(**_ann("funding_hedge_execute", "Execute funding hedge")) + def funding_hedge_execute( + coin: str = "BTC", + dry_run: bool = True, + mainnet: bool = False, + max_hedge_notional_usd: Optional[float] = None, + confirmed: bool = False, + ctx: FastMCPContext = None, + ) -> str: + """Execute the live CFI v2 hedge path through MCP. + + This wraps `hl hedge execute`. Live execution requires confirmed=true + and a signing context from trusted MCP headers, environment, keystore, + private key, or local `hl auth import` scoped-token storage. + """ + if not dry_run and not confirmed: + return _json_error("funding_hedge_execute requires confirmed=true unless dry_run=true.") + env_overrides = _request_env(ctx) + error = _context_limit_error( + "funding_hedge_execute", + env_overrides, + mainnet=mainnet, + confirmed=confirmed, + require_signing=True, + ) + if error: + return _json_error(error) + hedge_cap = max_hedge_notional_usd + env_cap = _effective_env("NUNCHI_MAX_HEDGE_NOTIONAL", env_overrides) + if hedge_cap is None and env_cap: + try: + hedge_cap = float(env_cap) + except ValueError: + return _json_error("invalid NUNCHI_MAX_HEDGE_NOTIONAL in scoped context.") + if hedge_cap is not None and hedge_cap <= 0: + return _json_error("max_hedge_notional_usd must be positive.") + args = ["hedge", "execute", coin] + if dry_run: + args.append("--dry-run") + else: + args.append("--yes") + if hedge_cap is not None: + args.extend(["--max-hedge-notional", str(hedge_cap)]) + if mainnet: + args.append("--mainnet") + return _run_hl(*args, timeout=300, env_overrides=env_overrides) + @mcp.tool(**_ann("account", "Account state")) def account(mainnet: bool = False, ctx: FastMCPContext = None) -> str: """Get Hyperliquid account state (balances, positions).""" diff --git a/cli/web_auth.py b/cli/web_auth.py index 4f335ca..33e2e30 100644 --- a/cli/web_auth.py +++ b/cli/web_auth.py @@ -9,7 +9,9 @@ import os import secrets import time -from dataclasses import dataclass +import json +from dataclasses import asdict, dataclass +from pathlib import Path from typing import Any, Callable, Optional import requests @@ -20,6 +22,7 @@ PAIR_TOKEN_ENV = "NUNCHI_WEB_AUTH_PAIR_TOKEN" PAIR_ADDRESS_ENV = "NUNCHI_WEB_AUTH_ADDRESS" +SCOPED_TOKEN_PATH_ENV = "NUNCHI_SCOPED_TOKEN_PATH" class WebAuthMissingError(RuntimeError): @@ -41,6 +44,118 @@ class WebAuthPairing: account_id: str = "" +@dataclass(frozen=True) +class ScopedToken: + token: str + address: str + account_id: str = "" + permission_tier: str = "testnet_trading" + network: str = "testnet" + allow_mainnet: bool = False + max_order_size: Optional[float] = None + max_hedge_notional: Optional[float] = None + max_strategy_ticks: Optional[int] = None + require_confirmation: bool = True + created_at_ms: int = 0 + + def to_json(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_json(cls, raw: dict[str, Any]) -> "ScopedToken": + def _bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + return cls( + token=str(raw["token"]), + address=str(raw["address"]), + account_id=str(raw.get("account_id", "")), + permission_tier=str(raw.get("permission_tier", "testnet_trading")), + network=str(raw.get("network", "testnet")), + allow_mainnet=_bool(raw.get("allow_mainnet"), False), + max_order_size=( + float(raw["max_order_size"]) + if raw.get("max_order_size") not in (None, "") + else None + ), + max_hedge_notional=( + float(raw["max_hedge_notional"]) + if raw.get("max_hedge_notional") not in (None, "") + else None + ), + max_strategy_ticks=( + int(raw["max_strategy_ticks"]) + if raw.get("max_strategy_ticks") not in (None, "") + else None + ), + require_confirmation=_bool(raw.get("require_confirmation"), True), + created_at_ms=int(raw.get("created_at_ms") or int(time.time() * 1000)), + ) + + def to_pairing(self) -> WebAuthPairing: + return WebAuthPairing(token=self.token, address=self.address, account_id=self.account_id) + + def to_env(self) -> dict[str, str]: + env = { + PAIR_TOKEN_ENV: self.token, + PAIR_ADDRESS_ENV: self.address, + "NUNCHI_TRADING_PERMISSION_TIER": self.permission_tier, + "NUNCHI_TRADING_NETWORK": self.network, + "NUNCHI_ALLOW_MAINNET": "true" if self.allow_mainnet else "false", + "NUNCHI_REQUIRE_CONFIRMATION": "true" if self.require_confirmation else "false", + } + if self.account_id: + env["NUNCHI_ACCOUNT_ID"] = self.account_id + if self.max_order_size is not None: + env["NUNCHI_MAX_ORDER_SIZE"] = str(self.max_order_size) + if self.max_hedge_notional is not None: + env["NUNCHI_MAX_HEDGE_NOTIONAL"] = str(self.max_hedge_notional) + if self.max_strategy_ticks is not None: + env["NUNCHI_MAX_STRATEGY_TICKS"] = str(self.max_strategy_ticks) + return env + + +def scoped_token_path() -> Path: + return Path(os.environ.get(SCOPED_TOKEN_PATH_ENV, "~/.hl-agent/scoped-token.json")).expanduser() + + +def load_scoped_token() -> Optional[ScopedToken]: + path = scoped_token_path() + if not path.exists(): + return None + try: + return ScopedToken.from_json(json.loads(path.read_text("utf-8"))) + except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + + +def save_scoped_token(token: ScopedToken) -> Path: + path = scoped_token_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(token.to_json(), indent=2) + "\n", "utf-8") + try: + path.chmod(0o600) + except OSError: + pass + return path + + +def clear_scoped_token() -> None: + try: + scoped_token_path().unlink() + except FileNotFoundError: + pass + + +def scoped_token_env() -> dict[str, str]: + token = load_scoped_token() + return token.to_env() if token is not None else {} + + def pairing_from_env() -> Optional[WebAuthPairing]: token = os.environ.get(PAIR_TOKEN_ENV, "").strip() address = ( @@ -50,7 +165,8 @@ def pairing_from_env() -> Optional[WebAuthPairing]: ) account_id = os.environ.get("NUNCHI_ACCOUNT_ID", "").strip() if not token or not address: - return None + scoped = load_scoped_token() + return scoped.to_pairing() if scoped is not None else None return WebAuthPairing(token=token, address=address, account_id=account_id) diff --git a/modules/funding_hedge.py b/modules/funding_hedge.py index 5cb0b31..16422ce 100644 --- a/modules/funding_hedge.py +++ b/modules/funding_hedge.py @@ -109,10 +109,17 @@ def funding_hedge_info() -> dict[str, object]: "default_vol_multiplier": BTCSWP_PROFILE["vol_multiplier"], "sizing_rule": "same-side BTCSWP, hedge_notional = perp_notional / vol_multiplier", "supported_cli": [ + "hl auth import --token ... --address ...", "hl hedge propose --perp-notional ... --funding-apr ...", + "hl hedge execute BTC --yes", "hl hedge backtest --csv ... --perp-notional ...", ], - "mcp_tools": ["funding_hedge_info", "funding_hedge_propose", "funding_hedge_backtest"], + "mcp_tools": [ + "funding_hedge_info", + "funding_hedge_propose", + "funding_hedge_backtest", + "funding_hedge_execute", + ], "csv_required_columns": ["funding_rate_8h", "perp_funding_rate_8h", "funding_rate", "rate"], "csv_optional_columns": ["hedge_rate_8h", "btcswp_rate_8h", "btcswp_funding_rate_8h"], "hedge_agent_distinction": ( @@ -121,7 +128,8 @@ def funding_hedge_info() -> dict[str, object]: ), "execution_boundary": ( "funding_hedge_info/propose/backtest do not place orders, sign payloads, " - "fetch private account state, or expose private rate methodology." + "fetch private account state, or expose private rate methodology. " + "funding_hedge_execute is live and requires confirmed=true plus a signing context." ), } diff --git a/tests/test_auth_scoped_token.py b/tests/test_auth_scoped_token.py new file mode 100644 index 0000000..ec49db6 --- /dev/null +++ b/tests/test_auth_scoped_token.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from cli.main import app + + +runner = CliRunner() + + +def test_auth_import_status_export_and_revoke(monkeypatch, tmp_path): + token_path = tmp_path / "scoped-token.json" + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(token_path)) + + result = runner.invoke( + app, + [ + "auth", + "import", + "--token", + "scoped-token-123", + "--address", + "0x" + "8" * 40, + "--permission-tier", + "testnet_trading", + "--network", + "testnet", + "--max-order-size", + "0.5", + "--max-hedge-notional", + "12000", + "--json", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["stored"] is True + assert payload["token"] != "scoped-token-123" + assert token_path.exists() + + status = runner.invoke(app, ["auth", "status", "--json"]) + assert status.exit_code == 0 + status_payload = json.loads(status.stdout) + assert status_payload["configured"] is True + assert status_payload["address"] == "0x" + "8" * 40 + assert status_payload["max_order_size"] == 0.5 + assert status_payload["max_hedge_notional"] == 12000.0 + + exported = runner.invoke(app, ["auth", "export-env"]) + assert exported.exit_code == 0 + assert "export NUNCHI_WEB_AUTH_PAIR_TOKEN='scoped-token-123'" in exported.stdout + assert "export NUNCHI_WEB_AUTH_ADDRESS='0x" + "8" * 40 in exported.stdout + assert "export NUNCHI_MAX_HEDGE_NOTIONAL='12000.0'" in exported.stdout + + revoked = runner.invoke(app, ["auth", "revoke"]) + assert revoked.exit_code == 0 + assert not token_path.exists() + + empty = runner.invoke(app, ["auth", "status", "--json"]) + assert json.loads(empty.stdout)["configured"] is False diff --git a/tests/test_engine_strategies.py b/tests/test_engine_strategies.py index 64a6279..bc81f3a 100644 --- a/tests/test_engine_strategies.py +++ b/tests/test_engine_strategies.py @@ -259,7 +259,7 @@ def test_all_engine_strategies_registered(self): def test_total_strategies(self): from cli.strategy_registry import STRATEGY_REGISTRY - assert len(STRATEGY_REGISTRY) == 18 # 14 original + 4 directional + assert len(STRATEGY_REGISTRY) == 19 # 14 original + 4 directional + cfi_hedge def test_resolve_engine_strategies(self): from cli.strategy_registry import resolve_strategy_path diff --git a/tests/test_funding_hedge.py b/tests/test_funding_hedge.py index aae315d..7240081 100644 --- a/tests/test_funding_hedge.py +++ b/tests/test_funding_hedge.py @@ -137,6 +137,7 @@ def test_mcp_funding_hedge_info(monkeypatch): assert payload["deployed_profiles"][0]["asset"] == "BTC" assert "funding_hedge_propose" in payload["mcp_tools"] + assert "funding_hedge_execute" in payload["mcp_tools"] def test_mcp_funding_hedge_rejects_roadmap_assets(monkeypatch): @@ -231,3 +232,60 @@ def test_mcp_funding_hedge_backtest(monkeypatch, tmp_path): assert payload["periods"] == 1 assert payload["unhedged_cashflow_usd"] == -45 assert payload["hedge_cashflow_usd"] == 45 + + +def test_mcp_funding_hedge_execute_requires_confirmation(monkeypatch, tmp_path): + install_fake_mcp(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(tmp_path / "missing-token.json")) + monkeypatch.setenv("NUNCHI_WEB_AUTH_PAIR_TOKEN", "pair-token") + monkeypatch.setenv("NUNCHI_WEB_AUTH_ADDRESS", "0x" + "9" * 40) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + payload = json.loads(server.tools["funding_hedge_execute"](dry_run=False, confirmed=False)) + + assert "confirmed=true" in payload["error"] + + +def test_mcp_funding_hedge_execute_uses_local_scoped_token(monkeypatch, tmp_path): + install_fake_mcp(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(tmp_path / "scoped-token.json")) + + from cli.web_auth import ScopedToken, save_scoped_token + + save_scoped_token( + ScopedToken( + token="stored-token", + address="0x" + "a" * 40, + permission_tier="testnet_trading", + network="testnet", + max_hedge_notional=12_000, + require_confirmation=True, + ) + ) + + import cli.mcp_server as mcp_server + from cli.mcp_server import create_mcp_server + + captured = {} + + def fake_run_hl(*args, timeout=30, env_overrides=None): + captured["args"] = args + captured["timeout"] = timeout + captured["env_overrides"] = env_overrides + return "executed" + + monkeypatch.setattr(mcp_server, "_run_hl", fake_run_hl) + + server = create_mcp_server() + output = server.tools["funding_hedge_execute"](coin="BTC", dry_run=False, confirmed=True) + + assert output == "executed" + assert captured["args"] == ("hedge", "execute", "BTC", "--yes", "--max-hedge-notional", "12000.0") + assert captured["timeout"] == 300 + assert captured["env_overrides"]["NUNCHI_WEB_AUTH_PAIR_TOKEN"] == "stored-token" + assert captured["env_overrides"]["NUNCHI_WEB_AUTH_ADDRESS"] == "0x" + "a" * 40 + assert captured["env_overrides"]["NUNCHI_MAX_HEDGE_NOTIONAL"] == "12000.0" diff --git a/tests/test_hedge_margin_port.py b/tests/test_hedge_margin_port.py index 0aa03d9..16a17f3 100644 --- a/tests/test_hedge_margin_port.py +++ b/tests/test_hedge_margin_port.py @@ -254,6 +254,51 @@ def fail_persist(hedges): assert persisted is False +def test_hedge_execute_respects_max_hedge_notional(monkeypatch): + import cli.commands.hedge as hedge_cmd + import cli.config as cfgmod + import cli.hl_adapter as adapter_mod + import parent.hl_proxy as proxy_mod + + class FakeDirectHLProxy: + placed = False + + def __init__(self, raw_hl): + self.raw_hl = raw_hl + + def place_order(self, **kwargs): + FakeDirectHLProxy.placed = True + raise AssertionError("place_order should not be called when cap rejects") + + profile = SimpleNamespace(cfi_instrument="yex:BTCSWP", baseline_b0=75_000.0) + proposal = SimpleNamespace( + profile=profile, + hedge_notional_usd=10_000.0, + legs=[SimpleNamespace(), SimpleNamespace(side="long")], + ) + snapshot = SimpleNamespace(oracle_px=75_000.0) + persisted = False + + def fail_persist(hedges): + nonlocal persisted + persisted = True + raise AssertionError("cap rejection should not persist hedge state") + + monkeypatch.setattr(cfgmod.TradingConfig, "get_private_key", lambda self: "0x" + "1" * 64) + monkeypatch.setattr(proxy_mod, "HLProxy", lambda private_key, testnet: object()) + monkeypatch.setattr(adapter_mod, "DirectHLProxy", FakeDirectHLProxy) + monkeypatch.setattr(hedge_cmd, "_build_proposal", lambda hl, coin: (proposal, snapshot)) + monkeypatch.setattr("cli.hedge_display.hedge_proposal_block", lambda proposal, snapshot, mainnet=False: "proposal") + monkeypatch.setattr(hedge_cmd, "_save_hedges", fail_persist) + + result = runner.invoke(app, ["hedge", "execute", "BTC", "--max-hedge-notional", "9999", "--yes"]) + + assert result.exit_code == 2 + assert "exceeds cap" in result.output + assert FakeDirectHLProxy.placed is False + assert persisted is False + + def _assert_margin_dry_run_does_not_open_hl(monkeypatch, args, expected_output): import cli.commands.margin as margin_cmd diff --git a/tests/test_mcp_annotations.py b/tests/test_mcp_annotations.py index 27baea2..4098495 100644 --- a/tests/test_mcp_annotations.py +++ b/tests/test_mcp_annotations.py @@ -13,7 +13,7 @@ def test_classification_sets_are_disjoint(): def test_destructive_set_covers_fund_movers(): from cli.mcp_server import _DESTRUCTIVE_TOOLS - for name in ("trade", "run_strategy", "apex_run", "schedule_cancel", "emergency_close_all"): + for name in ("trade", "run_strategy", "apex_run", "schedule_cancel", "emergency_close_all", "funding_hedge_execute"): assert name in _DESTRUCTIVE_TOOLS @@ -42,5 +42,6 @@ def test_server_applies_annotations(): assert by_name["trade"].annotations.readOnlyHint is False assert by_name["schedule_cancel"].annotations.destructiveHint is True assert by_name["emergency_close_all"].annotations.destructiveHint is True + assert by_name["funding_hedge_execute"].annotations.destructiveHint is True assert by_name["account"].annotations.readOnlyHint is True assert by_name["funding_rates"].annotations.readOnlyHint is True diff --git a/tests/test_web_auth_signer.py b/tests/test_web_auth_signer.py index 613f886..9c932ba 100644 --- a/tests/test_web_auth_signer.py +++ b/tests/test_web_auth_signer.py @@ -17,6 +17,21 @@ def test_pairing_from_env_prefers_web_auth_address(monkeypatch): assert pairing.address == "0x" + "1" * 40 +def test_pairing_from_env_falls_back_to_stored_scoped_token(monkeypatch, tmp_path): + from cli.web_auth import ScopedToken, pairing_from_env, save_scoped_token + + monkeypatch.delenv("NUNCHI_WEB_AUTH_PAIR_TOKEN", raising=False) + monkeypatch.delenv("NUNCHI_WEB_AUTH_ADDRESS", raising=False) + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(tmp_path / "scoped-token.json")) + save_scoped_token(ScopedToken(token="stored-token", address="0x" + "7" * 40)) + + pairing = pairing_from_env() + + assert pairing is not None + assert pairing.token == "stored-token" + assert pairing.address == "0x" + "7" * 40 + + def test_split_signature_normalizes_v(): from cli.web_auth import split_signature From 11f11e3823316edfc236d9cf26e1ac7358de4e71 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Mon, 29 Jun 2026 07:15:20 -0400 Subject: [PATCH 4/4] Enforce hedge execution policy Co-authored-by: Cursor --- cli/commands/hedge.py | 30 ++++++++++- cli/mcp_server.py | 17 ++++-- cli/session_policy.py | 2 + tests/test_funding_hedge.py | 40 ++++++++++++++ tests/test_hedge_margin_port.py | 89 +++++++++++++++++++++++++++++++ tests/test_mcp_gateway_context.py | 3 ++ 6 files changed, 177 insertions(+), 4 deletions(-) diff --git a/cli/commands/hedge.py b/cli/commands/hedge.py index 5515550..56daf41 100644 --- a/cli/commands/hedge.py +++ b/cli/commands/hedge.py @@ -250,6 +250,11 @@ def execute_cmd( "--max-hedge-notional", help="Reject execution if proposed BTCSWP hedge notional exceeds this USD cap.", ), + policy: Optional[Path] = typer.Option( + None, + "--policy", + help="Session policy file (or inline JSON / NUNCHI_SESSION_POLICY env).", + ), ): """Build the proposal and optionally sign + submit a real yex:{COIN}SWP order. @@ -261,8 +266,13 @@ def execute_cmd( from cli.display import BOLD, GREEN, RESET from cli.hedge_display import hedge_proposal_block from cli.hl_adapter import DirectHLProxy + from cli.session_policy import ACTION_HEDGE, current_workspace, guard_or_exit from parent.hl_proxy import HLProxy + network = "mainnet" if mainnet else "testnet" + policy_path = str(policy) if policy else None + guard_or_exit(ACTION_HEDGE, policy_path=policy_path, network=network) + cfg = TradingConfig() private_key = cfg.get_private_key() raw_hl = HLProxy(private_key=private_key, testnet=not mainnet) @@ -279,6 +289,15 @@ def execute_cmd( ) raise typer.Exit(2) + pol = guard_or_exit( + ACTION_HEDGE, + policy_path=policy_path, + wallet=getattr(hl, "_address", None), + network=network, + market=proposal.profile.cfi_instrument, + notional_usd=proposal.hedge_notional_usd, + ) + # Size the order in CFI v2 (BTCSWP) units. SDK rounds to szDecimals. wire_px = snapshot.oracle_px or proposal.profile.baseline_b0 size = proposal.hedge_notional_usd / wire_px @@ -322,6 +341,15 @@ def execute_cmd( f"{fill.instrument} @ {fill.price} (oid={fill.oid})" ) + if pol is not None and pol.daily_notional_limit_usd is not None: + from cli.session_policy import PolicyCounters + PolicyCounters().record( + getattr(hl, "_address", None), + network, + current_workspace(), + abs(float(fill.quantity) * float(fill.price)), + ) + # Persist the HedgeJob. job_id = f"HEDGE-{int(time.time() * 1000)}" job = { @@ -340,7 +368,7 @@ def execute_cmd( "status": "active", "cumulative_savings_usd": 0.0, "last_sample_at_ms": int(time.time() * 1000), - "network": "mainnet" if mainnet else "testnet", + "network": network, } hedges = _load_hedges() hedges.insert(0, job) diff --git a/cli/mcp_server.py b/cli/mcp_server.py index e57fa4a..5a25f33 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -190,7 +190,14 @@ def _policy_from_context_env(env: dict[str, str]) -> Optional[str]: if tier == "read_only": policy["allowed_actions"] = ["__read_only__"] elif tier in ("testnet_trading", "live_trading"): - policy["allowed_actions"] = ["trade", "run", "builder-approve"] + policy["allowed_actions"] = ["trade", "run", "builder-approve", "hedge"] + + hedge_cap = (env.get("NUNCHI_MAX_HEDGE_NOTIONAL") or "").strip() + if hedge_cap: + try: + policy["max_notional_usd_per_action"] = float(hedge_cap) + except ValueError: + pass if not policy: return None @@ -371,7 +378,11 @@ def _request_env(ctx: Any = None) -> dict[str, str]: def _local_scoped_env() -> dict[str, str]: try: from cli.web_auth import scoped_token_env - return scoped_token_env() + env = scoped_token_env() + policy = _policy_from_context_env(env) + if policy is not None: + env["NUNCHI_SESSION_POLICY"] = policy + return env except Exception: return {} @@ -640,7 +651,7 @@ def funding_hedge_execute( "funding_hedge_execute", env_overrides, mainnet=mainnet, - confirmed=confirmed, + confirmed=confirmed or dry_run, require_signing=True, ) if error: diff --git a/cli/session_policy.py b/cli/session_policy.py index 93c1135..e3f4974 100644 --- a/cli/session_policy.py +++ b/cli/session_policy.py @@ -59,6 +59,7 @@ ``run`` — start an autonomous trading loop (cli/commands/run.py) ``trade`` — place a single manual order (cli/commands/trade.py) ``builder-approve`` — approve a builder fee on-chain (cli/commands/builder.py) +``hedge`` — execute a BTCSWP funding hedge (cli/commands/hedge.py) Future commands should reuse these or add their own canonical name and pass it to ``guard_or_exit`` / ``enforce`` (e.g. ``fleet``, ``house``, ``hedge``, @@ -87,6 +88,7 @@ ACTION_RUN = "run" ACTION_TRADE = "trade" ACTION_BUILDER_APPROVE = "builder-approve" +ACTION_HEDGE = "hedge" class PolicyViolation(Exception): diff --git a/tests/test_funding_hedge.py b/tests/test_funding_hedge.py index 7240081..04385c7 100644 --- a/tests/test_funding_hedge.py +++ b/tests/test_funding_hedge.py @@ -289,3 +289,43 @@ def fake_run_hl(*args, timeout=30, env_overrides=None): assert captured["env_overrides"]["NUNCHI_WEB_AUTH_PAIR_TOKEN"] == "stored-token" assert captured["env_overrides"]["NUNCHI_WEB_AUTH_ADDRESS"] == "0x" + "a" * 40 assert captured["env_overrides"]["NUNCHI_MAX_HEDGE_NOTIONAL"] == "12000.0" + policy = json.loads(captured["env_overrides"]["NUNCHI_SESSION_POLICY"]) + assert "hedge" in policy["allowed_actions"] + assert policy["max_notional_usd_per_action"] == 12000.0 + + +def test_mcp_funding_hedge_dry_run_does_not_require_confirmation(monkeypatch, tmp_path): + install_fake_mcp(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("NUNCHI_SCOPED_TOKEN_PATH", str(tmp_path / "scoped-token.json")) + + from cli.web_auth import ScopedToken, save_scoped_token + + save_scoped_token( + ScopedToken( + token="stored-token", + address="0x" + "b" * 40, + permission_tier="testnet_trading", + network="testnet", + require_confirmation=True, + ) + ) + + import cli.mcp_server as mcp_server + from cli.mcp_server import create_mcp_server + + captured = {} + + def fake_run_hl(*args, timeout=30, env_overrides=None): + captured["args"] = args + captured["env_overrides"] = env_overrides + return "dry-run preview" + + monkeypatch.setattr(mcp_server, "_run_hl", fake_run_hl) + + server = create_mcp_server() + output = server.tools["funding_hedge_execute"](coin="BTC") + + assert output == "dry-run preview" + assert captured["args"] == ("hedge", "execute", "BTC", "--dry-run") + assert captured["env_overrides"]["NUNCHI_REQUIRE_CONFIRMATION"] == "true" diff --git a/tests/test_hedge_margin_port.py b/tests/test_hedge_margin_port.py index 16a17f3..96f163f 100644 --- a/tests/test_hedge_margin_port.py +++ b/tests/test_hedge_margin_port.py @@ -299,6 +299,95 @@ def fail_persist(hedges): assert persisted is False +def test_hedge_execute_enforces_session_policy_action(monkeypatch): + import cli.commands.hedge as hedge_cmd + import cli.config as cfgmod + import cli.hl_adapter as adapter_mod + import parent.hl_proxy as proxy_mod + + class FakeDirectHLProxy: + placed = False + + def __init__(self, raw_hl): + self.raw_hl = raw_hl + + def place_order(self, **kwargs): + FakeDirectHLProxy.placed = True + raise AssertionError("place_order should not be called when policy rejects") + + profile = SimpleNamespace(cfi_instrument="yex:BTCSWP", baseline_b0=75_000.0) + proposal = SimpleNamespace( + profile=profile, + hedge_notional_usd=10_000.0, + legs=[SimpleNamespace(), SimpleNamespace(side="long")], + ) + snapshot = SimpleNamespace(oracle_px=75_000.0) + + monkeypatch.setattr(cfgmod.TradingConfig, "get_private_key", lambda self: "0x" + "1" * 64) + monkeypatch.setattr(proxy_mod, "HLProxy", lambda private_key, testnet: object()) + monkeypatch.setattr(adapter_mod, "DirectHLProxy", FakeDirectHLProxy) + monkeypatch.setattr(hedge_cmd, "_build_proposal", lambda hl, coin: (proposal, snapshot)) + monkeypatch.setattr("cli.hedge_display.hedge_proposal_block", lambda proposal, snapshot, mainnet=False: "proposal") + + result = runner.invoke( + app, + ["hedge", "execute", "BTC", "--dry-run", "--policy", '{"allowed_actions": ["trade"]}'], + ) + + assert result.exit_code == 2 + assert "REFUSED by session policy" in result.output + assert "action 'hedge'" in result.output + assert FakeDirectHLProxy.placed is False + + +def test_hedge_execute_enforces_session_policy_notional(monkeypatch): + import cli.commands.hedge as hedge_cmd + import cli.config as cfgmod + import cli.hl_adapter as adapter_mod + import parent.hl_proxy as proxy_mod + + class FakeDirectHLProxy: + placed = False + + def __init__(self, raw_hl): + self.raw_hl = raw_hl + + def place_order(self, **kwargs): + FakeDirectHLProxy.placed = True + raise AssertionError("place_order should not be called when policy rejects") + + profile = SimpleNamespace(cfi_instrument="yex:BTCSWP", baseline_b0=75_000.0) + proposal = SimpleNamespace( + profile=profile, + hedge_notional_usd=10_000.0, + legs=[SimpleNamespace(), SimpleNamespace(side="long")], + ) + snapshot = SimpleNamespace(oracle_px=75_000.0) + + monkeypatch.setattr(cfgmod.TradingConfig, "get_private_key", lambda self: "0x" + "1" * 64) + monkeypatch.setattr(proxy_mod, "HLProxy", lambda private_key, testnet: object()) + monkeypatch.setattr(adapter_mod, "DirectHLProxy", FakeDirectHLProxy) + monkeypatch.setattr(hedge_cmd, "_build_proposal", lambda hl, coin: (proposal, snapshot)) + monkeypatch.setattr("cli.hedge_display.hedge_proposal_block", lambda proposal, snapshot, mainnet=False: "proposal") + + result = runner.invoke( + app, + [ + "hedge", + "execute", + "BTC", + "--dry-run", + "--policy", + '{"allowed_actions": ["hedge"], "max_notional_usd_per_action": 9999}', + ], + ) + + assert result.exit_code == 2 + assert "REFUSED by session policy" in result.output + assert "per-action limit" in result.output + assert FakeDirectHLProxy.placed is False + + def _assert_margin_dry_run_does_not_open_hl(monkeypatch, args, expected_output): import cli.commands.margin as margin_cmd diff --git a/tests/test_mcp_gateway_context.py b/tests/test_mcp_gateway_context.py index c57b0fc..a8d7c24 100644 --- a/tests/test_mcp_gateway_context.py +++ b/tests/test_mcp_gateway_context.py @@ -32,6 +32,7 @@ def test_trusted_gateway_headers_become_scoped_env(monkeypatch): "x-nunchi-trading-permission-tier": "testnet_trading", "x-nunchi-trading-network": "testnet", "x-nunchi-max-order-size": "0.5", + "x-nunchi-max-hedge-notional": "12000", "x-nunchi-max-strategy-ticks": "12", }) @@ -45,6 +46,8 @@ def test_trusted_gateway_headers_become_scoped_env(monkeypatch): assert policy["wallets"] == ["0x" + "2" * 40] assert policy["network"] == "testnet" assert "trade" in policy["allowed_actions"] + assert "hedge" in policy["allowed_actions"] + assert policy["max_notional_usd_per_action"] == 12000.0 def test_context_limits_fail_closed_without_signing_context(monkeypatch, tmp_path):