diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 1544778..f0f7784 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -8,6 +8,7 @@ import json import subprocess import sys +from pathlib import Path from typing import Optional @@ -21,6 +22,17 @@ def _run_hl(*args: str, timeout: int = 30) -> str: return output or "(no output)" +def _run_script(script_name: str, *args: str, timeout: int = 300) -> str: + """Run a repository script via subprocess and return stdout/stderr.""" + script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name + cmd = [sys.executable, str(script_path), *args] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + output = result.stdout.strip() + if result.returncode != 0 and result.stderr: + output = output + "\n" + result.stderr.strip() if output else result.stderr.strip() + return output or "(no output)" + + def create_mcp_server(): """Create and configure the FastMCP server.""" from mcp.server.fastmcp import FastMCP @@ -210,6 +222,63 @@ def run_strategy( args.append("--mainnet") return _run_hl(*args, timeout=max(60, (max_ticks or 10) * tick + 30)) + @mcp.tool() + def hedge_agent_smoke_test( + instrument: str = "ETH-PERP", + position_qty: float = 5.0, + inventory_threshold: float = 3.0, + notional_threshold: Optional[float] = None, + urgency_factor: float = 0.5, + max_hedge_size: float = 5.0, + slippage_bps: float = 10.0, + mainnet_account_check: bool = False, + sam_address: Optional[str] = None, + send_testnet_usdc: Optional[str] = None, + confirm_send_testnet_usdc: bool = False, + ) -> str: + """Run Sam's hedge_agent CLI smoke test through MCP. + + Exercises the real `hl run hedge_agent` path in mock mode with seeded + long and short positions, then validates the first hedge fill. Optional + mainnet verification is read-only (`hl account --mainnet`). Optional + testnet USDC transfer requires confirm_send_testnet_usdc=true. + + Args: + instrument: Trading instrument for the mock hedge run. + position_qty: Absolute seeded position size for long/short cases. + inventory_threshold: Quantity threshold used unless notional_threshold is set. + notional_threshold: Optional USD notional threshold. + urgency_factor: Hedge sizing multiplier. + max_hedge_size: Maximum hedge order size. + slippage_bps: IOC slippage budget in basis points. + mainnet_account_check: Also run read-only mainnet account verification. + sam_address: Destination address for optional testnet USDC transfer. + send_testnet_usdc: Optional testnet USDC amount to transfer to sam_address. + confirm_send_testnet_usdc: Must be true to submit the testnet transfer. + """ + if send_testnet_usdc and not confirm_send_testnet_usdc: + return "Refusing to move testnet USDC without confirm_send_testnet_usdc=true." + if send_testnet_usdc and not sam_address: + return "Refusing to move testnet USDC without sam_address." + + args = [ + "--instrument", instrument, + "--position-qty", str(position_qty), + "--urgency-factor", str(urgency_factor), + "--max-hedge-size", str(max_hedge_size), + "--slippage-bps", str(slippage_bps), + ] + if notional_threshold is None: + args.extend(["--inventory-threshold", str(inventory_threshold)]) + else: + args.extend(["--notional-threshold", str(notional_threshold)]) + if mainnet_account_check: + args.append("--mainnet-account-check") + if send_testnet_usdc: + args.extend(["--sam-address", sam_address or "", "--send-testnet-usdc", send_testnet_usdc]) + + return _run_script("test_hedge_agent.py", *args, timeout=600) + @mcp.tool() def radar_run(mock: bool = False) -> str: """Run opportunity radar — screen HL perps for trading setups.""" diff --git a/scripts/test_hedge_agent.py b/scripts/test_hedge_agent.py new file mode 100644 index 0000000..6ead2c6 --- /dev/null +++ b/scripts/test_hedge_agent.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""End-to-end hedge_agent smoke test for Sam. + +The script exercises the same CLI path an operator uses: + + python -m cli.main run hedge_agent --mock --max-ticks 1 + +It seeds a saved long and short position into a temporary StateDB, runs one +mock tick for each side, and validates that the first fill is the expected IOC +hedge. Optional flags can also do a read-only mainnet account check and send +testnet USDC to a provided address. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +from decimal import Decimal +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _run(cmd: list[str], *, env: dict[str, str], cwd: Path = REPO_ROOT) -> subprocess.CompletedProcess[str]: + print(f"$ {' '.join(cmd)}") + result = subprocess.run( + cmd, + cwd=str(cwd), + env=env, + text=True, + capture_output=True, + check=False, + ) + if result.stdout: + print(result.stdout.rstrip()) + if result.stderr: + print(result.stderr.rstrip(), file=sys.stderr) + return result + + +def _seed_position(data_dir: Path, instrument: str, position_qty: float, entry_price: float) -> None: + sys.path.insert(0, str(REPO_ROOT)) + from parent.position_tracker import PositionTracker + from parent.store import StateDB + + tracker = PositionTracker() + side = "buy" if position_qty > 0 else "sell" + tracker.apply_fill( + "hedge_agent", + instrument, + side, + Decimal(str(abs(position_qty))), + Decimal(str(entry_price)), + ) + + db = StateDB(path=str(data_dir / "state.db")) + db.put("tick_count", 0) + db.put("positions", tracker.to_dict()) + db.put("strategy_id", "hedge_agent") + db.put("instrument", instrument) + db.put("start_time_ms", int(time.time() * 1000)) + db.put("order_stats", {"total_placed": 0, "total_filled": 0}) + db.close() + + +def _write_config( + path: Path, + *, + inventory_threshold: float | None, + notional_threshold: float | None, + urgency_factor: float, + max_hedge_size: float, + slippage_bps: float, +) -> None: + params: dict[str, Any] = { + "urgency_factor": urgency_factor, + "max_hedge_size": max_hedge_size, + "slippage_bps": slippage_bps, + } + if notional_threshold is None: + params["inventory_threshold"] = inventory_threshold + else: + params["notional_threshold"] = notional_threshold + + lines = ["strategy_params:"] + for key, value in params.items(): + lines.append(f" {key}: {value}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _read_trades(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + trades: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if line.strip(): + trades.append(json.loads(line)) + return trades + + +def _expected_size( + *, + position_qty: float, + inventory_threshold: float | None, + urgency_factor: float, + max_hedge_size: float, +) -> float | None: + if inventory_threshold is None: + return None + excess = abs(position_qty) - inventory_threshold + if excess <= 0: + return 0.0 + return round(min(excess * urgency_factor, max_hedge_size), 6) + + +def _run_case(args: argparse.Namespace, work_root: Path, position_qty: float) -> dict[str, Any]: + label = "long" if position_qty > 0 else "short" + data_dir = work_root / label + data_dir.mkdir(parents=True, exist_ok=True) + config_path = data_dir / "hedge_config.yaml" + + _seed_position(data_dir, args.instrument, position_qty, args.entry_price) + _write_config( + config_path, + inventory_threshold=args.inventory_threshold, + notional_threshold=args.notional_threshold, + urgency_factor=args.urgency_factor, + max_hedge_size=args.max_hedge_size, + slippage_bps=args.slippage_bps, + ) + + env = os.environ.copy() + env["PYTHONPATH"] = str(REPO_ROOT) + env["HL_TESTNET"] = "true" + + result = _run( + [ + sys.executable, + "-m", + "cli.main", + "run", + "hedge_agent", + "--instrument", + args.instrument, + "--config", + str(config_path), + "--data-dir", + str(data_dir), + "--tick", + "0", + "--max-ticks", + "1", + "--mock", + ], + env=env, + ) + if result.returncode != 0: + raise RuntimeError(f"{label} hedge run failed with exit code {result.returncode}") + + trades = _read_trades(data_dir / "trades.jsonl") + if not trades: + raise RuntimeError(f"{label} hedge run produced no trades") + + first = trades[0] + expected_side = "sell" if position_qty > 0 else "buy" + if first.get("side") != expected_side: + raise RuntimeError(f"{label} expected first hedge side {expected_side}, got {first.get('side')}") + + quantity = float(first["quantity"]) + if quantity <= 0 or quantity > args.max_hedge_size: + raise RuntimeError(f"{label} invalid hedge quantity {quantity}") + + expected_size = _expected_size( + position_qty=position_qty, + inventory_threshold=args.inventory_threshold if args.notional_threshold is None else None, + urgency_factor=args.urgency_factor, + max_hedge_size=args.max_hedge_size, + ) + if expected_size is not None and abs(quantity - expected_size) > 1e-9: + raise RuntimeError(f"{label} expected hedge quantity {expected_size}, got {quantity}") + + print(f"OK {label}: first hedge {first['side']} {first['quantity']} {first['instrument']} @ {first['price']}") + return first + + +def _mainnet_account_check() -> None: + env = os.environ.copy() + env["PYTHONPATH"] = str(REPO_ROOT) + env["HL_TESTNET"] = "false" + result = _run([sys.executable, "-m", "cli.main", "account", "--mainnet"], env=env) + if result.returncode != 0: + raise RuntimeError("mainnet account check failed") + print("OK mainnet account check completed (read-only)") + + +def _send_testnet_usdc(address: str, amount: str) -> None: + env = os.environ.copy() + env["PYTHONPATH"] = str(REPO_ROOT) + env["HL_TESTNET"] = "true" + result = _run( + [ + sys.executable, + "-m", + "cli.main", + "money", + "transfer", + "usd", + amount, + address, + "--yes", + ], + env=env, + ) + if result.returncode != 0: + raise RuntimeError("testnet USDC transfer failed") + print(f"OK sent {amount} testnet USDC to {address}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run hedge_agent CLI smoke checks for Sam.") + parser.add_argument("--instrument", default="ETH-PERP") + parser.add_argument("--position-qty", type=float, default=5.0, help="Absolute seeded position for long/short cases") + parser.add_argument("--entry-price", type=float, default=2500.0) + parser.add_argument("--inventory-threshold", type=float, default=3.0) + parser.add_argument("--notional-threshold", type=float, default=None) + parser.add_argument("--urgency-factor", type=float, default=0.5) + parser.add_argument("--max-hedge-size", type=float, default=5.0) + parser.add_argument("--slippage-bps", type=float, default=10.0) + parser.add_argument("--mainnet-account-check", action="store_true", help="Run read-only hl account --mainnet") + parser.add_argument("--sam-address", help="Destination address for optional testnet USDC transfer") + parser.add_argument("--send-testnet-usdc", help="Amount of testnet USDC to send to --sam-address") + parser.add_argument("--artifacts-dir", type=Path, help="Keep artifacts in this directory instead of a temp dir") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.position_qty <= 0: + raise SystemExit("--position-qty must be positive") + if args.notional_threshold is not None: + args.inventory_threshold = None + if args.send_testnet_usdc and not args.sam_address: + raise SystemExit("--send-testnet-usdc requires --sam-address") + + if args.artifacts_dir: + work_root = args.artifacts_dir.resolve() + if work_root.exists(): + shutil.rmtree(work_root) + work_root.mkdir(parents=True) + cleanup = False + else: + tmp = tempfile.TemporaryDirectory(prefix="hedge-agent-") + work_root = Path(tmp.name) + cleanup = True + + try: + print(f"Artifacts: {work_root}") + _run_case(args, work_root, abs(args.position_qty)) + _run_case(args, work_root, -abs(args.position_qty)) + + if args.mainnet_account_check: + _mainnet_account_check() + + if args.send_testnet_usdc: + _send_testnet_usdc(args.sam_address, args.send_testnet_usdc) + + print("OK hedge_agent CLI smoke test passed") + return 0 + finally: + if cleanup: + tmp.cleanup() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/strategies/hedge_agent.py b/strategies/hedge_agent.py index 5068ad4..db45198 100644 --- a/strategies/hedge_agent.py +++ b/strategies/hedge_agent.py @@ -19,12 +19,14 @@ def __init__( self, strategy_id: str = "hedge_agent", inventory_threshold: float = 3.0, + notional_threshold: float | None = None, urgency_factor: float = 0.5, max_hedge_size: float = 5.0, slippage_bps: float = 10.0, ): super().__init__(strategy_id=strategy_id) self.inventory_threshold = inventory_threshold + self.notional_threshold = notional_threshold self.urgency_factor = urgency_factor self.max_hedge_size = max_hedge_size self.slippage_bps = slippage_bps @@ -38,12 +40,19 @@ def on_tick( return [] q = context.position_qty if context else 0.0 + threshold_qty = self.inventory_threshold - # Only hedge when inventory exceeds threshold - if abs(q) <= self.inventory_threshold: + # The CLI registry documents hedge_agent in notional terms; keep the + # original inventory threshold as the default for existing configs. + if self.notional_threshold is not None: + notional = abs(q) * snapshot.mid_price + if notional <= self.notional_threshold: + return [] + threshold_qty = self.notional_threshold / snapshot.mid_price + elif abs(q) <= self.inventory_threshold: return [] - excess = abs(q) - self.inventory_threshold + excess = abs(q) - threshold_qty hedge_size = min(excess * self.urgency_factor, self.max_hedge_size) hedge_size = round(hedge_size, 6) diff --git a/tests/test_mcp_hedge_tools.py b/tests/test_mcp_hedge_tools.py new file mode 100644 index 0000000..9a67537 --- /dev/null +++ b/tests/test_mcp_hedge_tools.py @@ -0,0 +1,79 @@ +"""Smoke tests for MCP hedge-agent helpers.""" +from __future__ import annotations + +import sys +import types + + +class FakeFastMCP: + last = None + + def __init__(self, *args, **kwargs): + self.tools = {} + FakeFastMCP.last = self + + def tool(self): + def decorator(fn): + self.tools[fn.__name__] = fn + return fn + + return decorator + + +def _install_fake_mcp(monkeypatch): + fastmcp_module = types.ModuleType("mcp.server.fastmcp") + fastmcp_module.FastMCP = FakeFastMCP + server_module = types.ModuleType("mcp.server") + server_module.fastmcp = fastmcp_module + mcp_module = types.ModuleType("mcp") + mcp_module.server = server_module + monkeypatch.setitem(sys.modules, "mcp", mcp_module) + monkeypatch.setitem(sys.modules, "mcp.server", server_module) + monkeypatch.setitem(sys.modules, "mcp.server.fastmcp", fastmcp_module) + + +def test_mcp_hedge_smoke_test_builds_script_call(monkeypatch): + _install_fake_mcp(monkeypatch) + + import cli.mcp_server as mcp_server + + calls = [] + monkeypatch.setattr(mcp_server, "_run_script", lambda *args, timeout=300: calls.append(args) or "ok") + server = mcp_server.create_mcp_server() + + assert server.tools["hedge_agent_smoke_test"]( + instrument="BTC-PERP", + position_qty=4.0, + notional_threshold=10000.0, + mainnet_account_check=True, + ) == "ok" + assert calls == [ + ( + "test_hedge_agent.py", + "--instrument", + "BTC-PERP", + "--position-qty", + "4.0", + "--urgency-factor", + "0.5", + "--max-hedge-size", + "5.0", + "--slippage-bps", + "10.0", + "--notional-threshold", + "10000.0", + "--mainnet-account-check", + ) + ] + + +def test_mcp_hedge_smoke_test_requires_confirm_for_testnet_transfer(monkeypatch): + _install_fake_mcp(monkeypatch) + + from cli.mcp_server import create_mcp_server + + server = create_mcp_server() + + assert server.tools["hedge_agent_smoke_test"](send_testnet_usdc="5", sam_address="0xabc") == ( + "Refusing to move testnet USDC without confirm_send_testnet_usdc=true." + ) diff --git a/tests/test_strategy_hedge_agent.py b/tests/test_strategy_hedge_agent.py index e35567a..8faab17 100644 --- a/tests/test_strategy_hedge_agent.py +++ b/tests/test_strategy_hedge_agent.py @@ -119,3 +119,14 @@ def test_order_type_is_ioc(self): strat = HedgeAgent(inventory_threshold=1.0) orders = strat.on_tick(_snap(), _ctx(pos_qty=5.0)) assert orders[0].order_type == "Ioc" + + def test_notional_threshold_uses_snapshot_price(self): + from strategies.hedge_agent import HedgeAgent + strat = HedgeAgent(notional_threshold=7500.0, urgency_factor=1.0) + + assert strat.on_tick(_snap(mid=2500.0), _ctx(pos_qty=3.0)) == [] + + orders = strat.on_tick(_snap(mid=2500.0), _ctx(pos_qty=4.0)) + assert len(orders) == 1 + assert orders[0].side == "sell" + assert orders[0].size == 1.0