diff --git a/README.md b/README.md index 5d87330..27c0560 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Strategies Tests License - MCP + MCP

@@ -75,6 +75,21 @@ 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 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 +``` + +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` + --- ## Strategies @@ -119,7 +134,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. | @@ -154,6 +169,7 @@ Built on the open [Agent Skills](https://agentskills.io) standard. Each skill is | **[Pulse](#pulse--emerging-pulse-detector)** | Detects sudden capital inflow via OI delta, volume surge, funding flips. IMMEDIATE signals at 100 confidence. | [`SKILL.md`](skills/pulse/SKILL.md) | | **[Guard (Dynamic Stop Loss)](#guard--dynamic-stop-loss)** | 2-phase trailing stop with tiered profit-locking. ROE-based triggers that auto-account for leverage. | [`SKILL.md`](skills/guard/SKILL.md) | | **[REFLECT](#reflect--performance-review)** | Nightly self-improvement loop. Analyzes every trade, finds patterns, generates actionable recommendations. | [`SKILL.md`](skills/reflect/SKILL.md) | +| **[BTCSWP Funding Hedge](#btcswp-funding-hedge)** | Thin wrapper over the MCP-exposed BTCSWP funding hedge calculator for BTC perp hedge proposals and backtests. | [`SKILL.md`](skills/btcswp-funding-hedge/SKILL.md) | ### Install a skill (agents) @@ -166,6 +182,7 @@ https://raw.githubusercontent.com/Nunchi-trade/agent-cli/main/skills/radar/SKILL https://raw.githubusercontent.com/Nunchi-trade/agent-cli/main/skills/pulse/SKILL.md https://raw.githubusercontent.com/Nunchi-trade/agent-cli/main/skills/guard/SKILL.md https://raw.githubusercontent.com/Nunchi-trade/agent-cli/main/skills/reflect/SKILL.md +https://raw.githubusercontent.com/Nunchi-trade/agent-cli/main/skills/btcswp-funding-hedge/SKILL.md ``` ### Install a skill (OpenClaw / ClawHub) @@ -351,6 +368,20 @@ All adjustments have guardrail bounds — parameters can't swing wildly. Disable --- +### BTCSWP Funding Hedge + +Agent skill wrapper for the existing read-only BTCSWP funding hedge calculator exposed over MCP. It sizes BTC perp funding hedges without duplicating the math or placing orders. + +```bash +hl hedge propose --asset BTC --side long --perp-notional 150000 --funding-apr 42 --json +``` + +MCP tools: `funding_hedge_info`, `funding_hedge_propose`, `funding_hedge_backtest` + +**[Download SKILL.md](skills/btcswp-funding-hedge/SKILL.md)** + +--- + ### Production Safety Built-in safety systems that protect positions even when the runner process crashes. @@ -460,6 +491,9 @@ 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 # Infrastructure hl builder approve [--mainnet] # Approve builder fee @@ -481,7 +515,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` +**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. @@ -618,7 +652,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 (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 b0e42f3..2292e59 100644 --- a/cli/commands/hedge.py +++ b/cli/commands/hedge.py @@ -150,15 +150,76 @@ 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 ───────────────────────────────────────────────────────────────── @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 +440,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 +470,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..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", + "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 = { @@ -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,87 @@ 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_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", + 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..5cb0b31 --- /dev/null +++ b/modules/funding_hedge.py @@ -0,0 +1,421 @@ +"""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", +} + +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: + 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 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"}: + 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_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( + [ + "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/skills/btcswp-funding-hedge/SKILL.md b/skills/btcswp-funding-hedge/SKILL.md new file mode 100644 index 0000000..7b699f5 --- /dev/null +++ b/skills/btcswp-funding-hedge/SKILL.md @@ -0,0 +1,135 @@ +--- +name: btcswp-funding-hedge +version: 1.0.0 +description: Calculate Nunchi BTCSWP funding hedge proposals through the existing MCP-exposed funding hedge calculator. Use when sizing BTC perp funding hedges, BTCSWP hedges, or Nunchi hedge proposals. +author: Nunchi Trade +tags: [btcswp, funding, hedge, btc, mcp, nunchi] +compatibility: Requires the yex-trader agent-cli MCP server with funding_hedge_info, funding_hedge_propose, and funding_hedge_backtest available. +metadata: + platform: yex-trader + exchange: hyperliquid + category: funding-hedge +--- + +# BTCSWP Funding Hedge + +Package the Nunchi BTCSWP funding hedge calculator for agents. This skill is a thin wrapper over the existing MCP tools: + +- `funding_hedge_info` +- `funding_hedge_propose` +- `funding_hedge_backtest` + +Do not reimplement, approximate, or edit the hedge math. The calculator lives in `modules/funding_hedge.py` and the MCP tools expose its output. + +## Agent Mandate + +You are sizing a read-only BTCSWP funding hedge for a BTC perp exposure. Your job is to collect the required inputs, call the Nunchi MCP tool, validate that it returned a proposal instead of an error, and present the returned hedge fields clearly. + +RULES: +- Use `funding_hedge_info` when you need to discover deployed assets, supported inputs, or caveats. +- ALWAYS call `funding_hedge_propose` for a new hedge proposal. +- NEVER calculate hedge notional yourself. +- NEVER place orders from this skill. It returns sizing only. +- NEVER claim support for ETH, HYPE, SPCX, or other assets unless the MCP tool accepts them. +- ALWAYS provide either `funding_apr` or `funding_rate_8h`. +- Treat `hedge_hl_coin` as the Hyperliquid coin identifier to use in downstream execution flows. + +## Proposal Inputs + +Call `funding_hedge_propose` with: + +```json +{ + "asset": "BTC", + "perp_side": "long", + "perp_notional_usd": 150000, + "funding_apr": 42, + "vol_multiplier": 15.0 +} +``` + +Input rules: +- `asset`: `BTC` is deployed today. +- `perp_side`: `long` or `short`. +- `perp_notional_usd`: absolute BTC perp notional in USD. +- `funding_apr`: annualized funding APR. The tool accepts `0.42` or `42` for 42%. +- `funding_rate_8h`: optional alternative to `funding_apr`, as a decimal like `0.0003`. +- `vol_multiplier`: optional. Default is `15.0`. + +## Proposal Output + +The MCP tool returns JSON. If it returns `{"error": "..."}`, stop and report the error. + +Key fields to present: +- `asset` +- `perp_side` +- `perp_notional_usd` +- `funding_apr` +- `hedge_market` +- `hedge_hl_coin` +- `hedge_side` +- `hedge_notional_usd` +- `vol_multiplier` +- `effective_hedged_notional_usd` +- `coverage_pct` +- `unhedged_funding_cashflow_usd_per_year` +- `target_hedge_cashflow_usd_per_year` +- `assumption` +- `disclaimer` + +There is no separate hedge-card schema in this repo. If the user asks for a hedge card, format the returned MCP fields as a concise summary card without changing values: + +```markdown +BTCSWP Hedge +Exposure: [perp_side] BTC perp $[perp_notional_usd] +Hedge: [hedge_side] $[hedge_notional_usd] [hedge_market] ([hedge_hl_coin]) +Coverage: [coverage_pct]% via [vol_multiplier]x multiplier +Funding APR: [funding_apr as percent] +Unhedged funding: $[abs(unhedged_funding_cashflow_usd_per_year)]/yr [paying or receiving] +Target offset: $[target_hedge_cashflow_usd_per_year]/yr +Status: sizing only; no order placed +``` + +## Backtest + +Use `funding_hedge_backtest` only when the user has a local CSV path visible to the MCP server process. + +Call shape: + +```json +{ + "csv_path": "/path/to/funding.csv", + "asset": "BTC", + "perp_side": "long", + "perp_notional_usd": 150000, + "vol_multiplier": 15.0 +} +``` + +CSV requirements: +- Required funding column: `funding_rate_8h`, `perp_funding_rate_8h`, `funding_rate`, or `rate`. +- Optional BTCSWP realized hedge column: `hedge_rate_8h`, `btcswp_rate_8h`, or `btcswp_funding_rate_8h`. +- Optional timestamp column: `timestamp`, `time`, or `date`. + +## CLI Fallback + +Use the MCP tools when the user has a connected agent. If MCP is unavailable and the repo is installed locally, pure sizing mode is: + +```bash +hl hedge propose --asset BTC --side long --perp-notional 150000 --funding-apr 42 --json +``` + +Passing `--perp-notional` keeps this in pure sizing mode. Without `--perp-notional`, the CLI may read account state and belongs to a separate connected-wallet flow. + +## Verification Example + +For `BTC`, `long`, `$150,000` perp notional, and `42%` APR, the MCP proposal should return: + +- `hedge_market`: `BTCSWP-USDYP` +- `hedge_hl_coin`: `yex:BTCSWP` +- `hedge_side`: `long` +- `hedge_notional_usd`: `10000.0` +- `coverage_pct`: `100.0` +- `unhedged_funding_cashflow_usd_per_year`: `-63000.0` +- `target_hedge_cashflow_usd_per_year`: `63000.0` + 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..aae315d --- /dev/null +++ b/tests/test_funding_hedge.py @@ -0,0 +1,233 @@ +"""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, + funding_hedge_info, + 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_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, + ["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_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) + + 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_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 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"])