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 @@
-
+
@@ -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 @@
-
+
@@ -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 @@
-
+
@@ -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):