diff --git a/tools/options-gps/PR_SUMMARY.md b/tools/options-gps/PR_SUMMARY.md new file mode 100644 index 0000000..d4bc967 --- /dev/null +++ b/tools/options-gps/PR_SUMMARY.md @@ -0,0 +1,61 @@ +# PR: Options GPS Autonomous Execution (Issue #26) + +## Summary + +Adds **autonomous trade execution** to Options GPS. The tool now supports submitting the recommended strategy directly to Deribit or Aevo, with a dry-run mode for simulated execution using mock exchange data. No changes to `pipeline.py` or `exchange.py` — the executor consumes their data classes and functions. + +Closes #26 + +## What's New + +- **`executor.py`** — new execution engine with: + - **Data classes**: `OrderRequest` (with strike/option_type for self-contained quote lookup), `OrderResult`, `ExecutionPlan`, `ExecutionReport` + - **Instrument name builders**: `deribit_instrument_name()` → `BTC-26FEB26-67500-C` (ISO 8601 → DDMonYY), `aevo_instrument_name()` → `BTC-67500-C` + - **ABC executor pattern**: `BaseExecutor` with three implementations: + - `DryRunExecutor` — offline simulation using exchange quote data, no network calls + - `DeribitExecutor` — REST API with Bearer token auth (`/public/auth` → `/private/buy|sell`) + - `AevoExecutor` — REST API with per-request HMAC-SHA256 signing (`AEVO-KEY`, `AEVO-TIMESTAMP`, `AEVO-SIGNATURE` headers) + - **Orchestration**: `build_execution_plan()` with auto-routing via `leg_divergences()` from `exchange.py`, `validate_plan()` pre-flight checks, `execute_plan()` with partial-fill warnings, `get_executor()` factory reading credentials from env vars + +- **CLI integration** (`main.py`) — 3 new flags: + - `--execute` — submit live orders (requires exchange credentials) + - `--dry-run` — simulate execution with mock exchange data (no API keys needed) + - `--exchange deribit|aevo` — force exchange (default: auto-route each leg to best venue) + - **Screen 5: Execution** — displays order plan, confirmation prompt (live mode), per-leg fill results, net cost summary + - Decision log JSON includes `"execution"` key with mode, fills, net cost + +- **Test coverage** — 37 new tests (156 total, all passing): + - `test_executor.py`: class-grouped unit tests — `TestInstrumentNames` (7), `TestBuildPlan` (6), `TestValidatePlan` (5), `TestDryRunExecutor` (6), `TestExecuteFlow` (4), `TestGetExecutor` (5) + - `test_executor_e2e.py`: full pipeline E2E — mock data → rank → build plan → dry-run execute → verify report + +## Files Changed + +| File | Change | +|------|--------| +| `tools/options-gps/executor.py` | **New** — execution engine (~290 lines) | +| `tools/options-gps/main.py` | Add `--execute`, `--dry-run`, `--exchange` flags + Screen 5 (~105 lines) | +| `tools/options-gps/tests/test_executor.py` | **New** — 33 unit tests in 6 class groups | +| `tools/options-gps/tests/test_executor_e2e.py` | **New** — 3 E2E tests | + +**Not modified**: `pipeline.py`, `exchange.py`, `requirements.txt`, `conftest.py` + +## Environment Variables + +| Variable | Purpose | Required for | +|---|---|---| +| `DERIBIT_CLIENT_ID` | Deribit API key | `--execute --exchange deribit` | +| `DERIBIT_CLIENT_SECRET` | Deribit API secret | same | +| `DERIBIT_TESTNET=1` | Use Deribit testnet | optional | +| `AEVO_API_KEY` | Aevo API key | `--execute --exchange aevo` | +| `AEVO_API_SECRET` | Aevo HMAC secret | same | +| `AEVO_TESTNET=1` | Use Aevo testnet | optional | + +None needed for `--dry-run`. + +## Test Plan + +- [ ] `python3 -m pytest tools/options-gps/tests/ -v` — all 156 tests pass +- [ ] `python3 tools/options-gps/main.py --symbol BTC --view bullish --risk medium --dry-run --no-prompt` — full dry-run flow with Screen 5 +- [ ] `python3 tools/options-gps/main.py --symbol BTC --view bullish --risk medium --no-prompt` — analysis-only flow unchanged (no Screen 5) +- [ ] `python3 tools/options-gps/main.py --symbol SPY --view bullish --risk medium --dry-run --no-prompt` — non-crypto graceful skip +- [ ] `python3 tools/options-gps/main.py --symbol BTC --view bullish --risk low --dry-run --exchange deribit --no-prompt` — forced exchange routing diff --git a/tools/options-gps/README.md b/tools/options-gps/README.md index a6bd8f8..cc18e71 100644 --- a/tools/options-gps/README.md +++ b/tools/options-gps/README.md @@ -9,6 +9,7 @@ Turn a trader's view into one clear options decision. Inputs: **symbol**, **mark - **Screen 2 (Top Plays):** Three ranked cards: Best Match (highest score for view), Safer Alternative (higher win probability), Higher Upside (higher expected payoff). Each shows why it fits, chance of profit, max loss, "Review again at" time. - **Screen 3 (Why This Works):** Distribution view and plain-English explanation for the best match (Synth 1h + 24h fusion state, required market behavior). - **Screen 4 (If Wrong):** Exit rule, convert/roll rule, time-based reassessment rule. +- **Screen 5 (Execution):** When `--execute` or `--dry-run` is used, shows order plan, optional confirmation (live only), and per-leg fill results with net cost. **Guardrails:** No-trade state when confidence is low, signals conflict (e.g. 1h vs 24h countermove), volatility is very high (directional views), or no vol edge exists (vol view with similar Synth/market IV). @@ -28,6 +29,60 @@ Turn a trader's view into one clear options decision. Inputs: **symbol**, **mark 7. **Guardrails:** Filters no-trade when fusion is countermove/unclear with directional view, volatility exceeds threshold (directional views), confidence is too low, or vol bias is neutral (vol view — no exploitable divergence between Synth and market IV). 8. **Risk Management:** Each strategy type has a specific risk plan (invalidation trigger, adjustment/reroute rule, review schedule). Short straddle/strangle are labeled "unlimited risk" with hard stops at 2x credit loss; they are risk-gated (high-only for short straddle, medium+ for short strangle). +## Exchange integration architecture + +Data flow for crypto assets (BTC, ETH, SOL): + +1. **Synth** → forecast percentiles, option pricing, volatility (via `SynthClient`). +2. **Pipeline** → strategy generation, payoff/EV, ranking (with optional exchange divergence bonus). +3. **Exchange (read)** → `exchange.py` fetches live or mock quotes from Deribit and Aevo; `leg_divergences()` computes per-leg best venue and price (lowest ask for BUY, highest bid for SELL). +4. **Execution** → `executor.py` builds an `ExecutionPlan` from the chosen strategy card, resolves instrument names per exchange (Deribit: `BTC-DDMonYY-STRIKE-C|P`; Aevo: `BTC-STRIKE-C|P`), and either simulates (dry-run) or submits orders. Deribit uses REST with Bearer token auth; Aevo uses REST with HMAC-SHA256 signing. When `--exchange` is not set, each leg is auto-routed to its best venue (per `leg_divergences`). + +Credentials are read from the environment (no secrets in code). Dry-run requires no credentials. + +## Execution + +Execution is supported only for **crypto assets** (BTC, ETH, SOL). Use `--execute` to submit live orders or `--dry-run` to simulate without placing orders. Non-crypto symbols exit with an error. + +**CLI flags:** + +| Flag | Description | +|------|-------------| +| `--execute [best\|safer\|upside]` | Submit live orders for the chosen card (default: `best`). | +| `--dry-run [best\|safer\|upside]` | Simulate execution using exchange quotes; no API keys, no real orders. | +| `--exchange deribit\|aevo` | Force all legs to one exchange. Default: auto-route each leg to best venue. | +| `--force` | Override no-trade guardrail for live execution. | +| `--size N` | Position size multiplier (scales all leg quantities and max loss). | +| `--max-slippage PCT` | Halt execution if any fill exceeds this slippage percentage. | +| `--max-loss USD` | Pre-trade risk check: reject if strategy max loss exceeds this budget. | +| `--timeout SECS` | Order monitoring timeout in seconds (default: 30). | +| `--log-file PATH` | Save full execution report JSON to file (audit trail). | + +**Exchange protocols:** + +- **Deribit**: JSON-RPC 2.0 over POST with Bearer token auth. Uses `contracts` parameter for unambiguous option sizing. Converts USD prices to BTC via index price lookup (`_get_index_price`), snaps to live order book best bid/ask (`_get_book_price`), and aligns to tick size (0.0005 BTC). Retries on transient errors (429, 502, 503, timeout) with exponential backoff. +- **Aevo**: REST API with per-request HMAC-SHA256 signing (`AEVO-KEY`, `AEVO-TIMESTAMP`, `AEVO-SIGNATURE` headers). Retries on transient errors. + +**Auto-routing**: When `--exchange` is not set, each leg is auto-routed to its best venue via `leg_divergences()`. For live execution, a per-exchange executor factory creates and caches separate authenticated sessions. + +**Safety features:** +- Guardrail blocks live execution when no-trade reason is active (override with `--force`). Dry-run is always allowed. +- Slippage protection halts multi-leg execution and warns about filled legs needing manual close. +- Max loss budget rejects plans before any orders are sent. +- Partial fill detection warns about filled legs on failure. +- Execution log JSON includes timestamp, per-fill slippage, and complete order/result audit. + +**Environment variables (live execution only):** + +| Variable | Purpose | +|----------|---------| +| `DERIBIT_CLIENT_ID` / `DERIBIT_CLIENT_SECRET` | Deribit API credentials | +| `DERIBIT_TESTNET=1` | Use Deribit testnet | +| `AEVO_API_KEY` / `AEVO_API_SECRET` | Aevo API credentials | +| `AEVO_TESTNET=1` | Use Aevo testnet | + +None needed for `--dry-run`. + ## Synth API usage - **`get_prediction_percentiles(asset, horizon)`** — 1h and 24h probabilistic price forecasts; used for fusion state and for payoff/EV (outcome distribution at expiry). @@ -43,6 +98,23 @@ python tools/options-gps/main.py # Vol view directly from CLI python tools/options-gps/main.py --symbol BTC --view vol --risk medium --no-prompt + +# Simulate execution — best match (default), no API keys needed +python tools/options-gps/main.py --symbol BTC --view bullish --risk medium --dry-run --no-prompt + +# Dry-run the safer alternative instead +python tools/options-gps/main.py --symbol BTC --view bullish --risk medium --dry-run safer --no-prompt + +# Execute best match on exchange (requires credentials) +python tools/options-gps/main.py --symbol BTC --view bullish --risk medium --execute --no-prompt + +# Execute the higher-upside card on Deribit, 3x size, with risk controls +python tools/options-gps/main.py --symbol ETH --view bearish --risk high \ + --execute upside --exchange deribit --size 3 --max-slippage 2.0 --max-loss 5000 \ + --log-file /tmp/eth_exec.json --no-prompt + +# Force execution despite no-trade guardrail +python tools/options-gps/main.py --symbol BTC --view bullish --risk medium --execute --force --no-prompt ``` Prompts: symbol (default BTC), view (bullish/bearish/neutral/vol), risk (low/medium/high). Uses mock data when no `SYNTH_API_KEY` is set. @@ -51,4 +123,4 @@ Prompts: symbol (default BTC), view (bullish/bearish/neutral/vol), risk (low/med From repo root: `python -m pytest tools/options-gps/tests/ -v`. No API key required (mock data). -Test coverage includes: forecast fusion, strategy generation (all views including vol), PnL calculations for all strategy types, CDF-weighted PoP/EV, ranking with vol bias, vol-specific guardrails, IV estimation, vol comparison, risk plans, hard filters, exchange data fetching/parsing, divergence computation, line shopping ranking integration, and end-to-end scripted tests. +Test coverage includes: forecast fusion, strategy generation (all views including vol), PnL calculations for all strategy types, CDF-weighted PoP/EV, ranking with vol bias, vol-specific guardrails, IV estimation, vol comparison, risk plans, hard filters, exchange data fetching/parsing, divergence computation, line shopping ranking integration, execution (instrument names, plan build/validate, dry-run executor, execution flow, auto-routing factory, slippage computation/protection, max loss budget validation, size multiplier, execution log save/load, retry logic for transient errors, per-exchange factory routing), full-pipeline-to-dry-run E2E, and end-to-end scripted tests. diff --git a/tools/options-gps/executor.py b/tools/options-gps/executor.py new file mode 100644 index 0000000..8f2902e --- /dev/null +++ b/tools/options-gps/executor.py @@ -0,0 +1,1045 @@ +"""Autonomous execution engine for Options GPS. +Consumes pipeline.py data classes and exchange.py pricing functions. +Supports Deribit (JSON-RPC 2.0), Aevo (REST + EIP-712 L2 signing), and dry-run simulation. +Auto-routing uses leg_divergences to pick the best venue per leg. +Includes order monitoring, auto-cancel on partial failure, slippage protection, +max-loss budget, position sizing, and execution audit logging.""" + +import hashlib +import hmac +import json +import os +import random +import time +import uuid +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timezone + +import requests + +try: + from eth_account import Account as _EthAccount + _HAS_ETH_ACCOUNT = True +except ImportError: + _HAS_ETH_ACCOUNT = False + +from exchange import best_execution_price, leg_divergences + + +# --- Helpers --- + +def _now_iso() -> str: + """Current UTC time as ISO 8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +# --- Data classes --- + +@dataclass +class OrderRequest: + instrument: str # "BTC-26FEB26-67500-C" (Deribit) or "BTC-67500-C" (Aevo) + action: str # "BUY" | "SELL" + quantity: int + order_type: str # "limit" | "market" + price: float # limit price from best_execution_price + exchange: str # "deribit" | "aevo" | "dry_run" + leg_index: int # index into strategy.legs + strike: float = 0.0 # strike price for quote lookup + option_type: str = "" # "call" | "put" for quote lookup + + +@dataclass +class OrderResult: + order_id: str + status: str # "filled" | "open" | "rejected" | "error" | "simulated" | "timeout" + fill_price: float + fill_quantity: int + instrument: str + action: str + exchange: str + error: str | None = None + slippage_pct: float = 0.0 # actual slippage vs limit price + timestamp: str = "" # ISO 8601 when order was placed + latency_ms: float = 0.0 # round-trip latency for the order + + +@dataclass +class ExecutionPlan: + strategy_description: str + strategy_type: str + exchange: str + asset: str + expiry: str + orders: list[OrderRequest] = field(default_factory=list) + estimated_cost: float = 0.0 + estimated_max_loss: float = 0.0 + dry_run: bool = False + timeout_seconds: float = 30.0 # order monitoring timeout + + +@dataclass +class ExecutionReport: + plan: ExecutionPlan + results: list[OrderResult] = field(default_factory=list) + all_filled: bool = False + net_cost: float = 0.0 + summary: str = "" + slippage_total: float = 0.0 # total slippage across all fills + started_at: str = "" # ISO 8601 execution start + finished_at: str = "" # ISO 8601 execution end + cancelled_orders: list[str] = field(default_factory=list) # order IDs cancelled on failure + + +# --- Instrument name builders --- + +def deribit_instrument_name(asset: str, expiry: str, strike: float, option_type: str) -> str: + """Build Deribit instrument name like BTC-26FEB26-67500-C. + Parses ISO 8601 expiry string to DDMonYY format.""" + strike_str = str(int(strike)) if strike == int(strike) else str(strike) + ot = "C" if option_type.lower() == "call" else "P" + date_part = _format_deribit_date(expiry) + return f"{asset}-{date_part}-{strike_str}-{ot}" + + +def aevo_instrument_name(asset: str, strike: float, option_type: str) -> str: + """Build Aevo instrument name like BTC-67500-C. No date in Aevo names.""" + strike_str = str(int(strike)) if strike == int(strike) else str(strike) + ot = "C" if option_type.lower() == "call" else "P" + return f"{asset}-{strike_str}-{ot}" + + +def _format_deribit_date(expiry: str) -> str: + """Parse ISO 8601 expiry to Deribit DDMonYY format (e.g. 26FEB26). + Falls back to UNKNOWN if parsing fails.""" + if not expiry: + return "UNKNOWN" + try: + expiry = expiry.replace("Z", "+00:00") + dt = datetime.fromisoformat(expiry) + return dt.strftime("%d%b%y").upper() + except (ValueError, TypeError): + return "UNKNOWN" + + +# --- Retry helpers --- + +def _is_retryable(err: Exception) -> bool: + """True for transient HTTP errors worth retrying (429, 502, 503, timeouts).""" + if isinstance(err, (requests.Timeout, requests.ConnectionError)): + return True + if isinstance(err, requests.HTTPError) and err.response is not None: + return err.response.status_code in (429, 502, 503) + return False + + +def _retry(fn, max_attempts: int = 3): + """Call fn() with exponential backoff on retryable errors.""" + for attempt in range(max_attempts): + try: + return fn() + except Exception as e: + if _is_retryable(e) and attempt < max_attempts - 1: + time.sleep(0.5 * (attempt + 1)) + continue + raise + + +# --- Executors --- + +class BaseExecutor(ABC): + @abstractmethod + def authenticate(self) -> bool: + ... + + @abstractmethod + def place_order(self, order: OrderRequest) -> OrderResult: + ... + + @abstractmethod + def get_order_status(self, order_id: str) -> OrderResult: + ... + + @abstractmethod + def cancel_order(self, order_id: str) -> bool: + ... + + +class DryRunExecutor(BaseExecutor): + """Simulates order execution using exchange quote data. No network calls. + Stateful: tracks placed orders for realistic status queries and cancellation.""" + + def __init__(self, exchange_quotes: list): + self.exchange_quotes = exchange_quotes + self._orders: dict[str, OrderResult] = {} + + def authenticate(self) -> bool: + return True + + def place_order(self, order: OrderRequest) -> OrderResult: + t0 = time.monotonic() + ts = _now_iso() + if not order.strike or not order.option_type: + result = OrderResult( + order_id=f"dry-{uuid.uuid4().hex[:8]}", + status="error", fill_price=0.0, fill_quantity=0, + instrument=order.instrument, action=order.action, + exchange="dry_run", error="Missing strike or option_type on order", + timestamp=ts, + ) + self._orders[result.order_id] = result + return result + quote = best_execution_price( + self.exchange_quotes, order.strike, order.option_type, order.action, + ) + if quote is None: + fill_price = order.price + else: + fill_price = quote.ask if order.action == "BUY" else quote.bid + slippage = _compute_slippage(order.price, fill_price, order.action) + latency = (time.monotonic() - t0) * 1000 + result = OrderResult( + order_id=f"dry-{uuid.uuid4().hex[:8]}", + status="simulated", + fill_price=fill_price, + fill_quantity=order.quantity, + instrument=order.instrument, + action=order.action, + exchange="dry_run", + slippage_pct=slippage, + timestamp=ts, + latency_ms=round(latency, 2), + ) + self._orders[result.order_id] = result + return result + + def get_order_status(self, order_id: str) -> OrderResult: + if order_id in self._orders: + return self._orders[order_id] + return OrderResult( + order_id=order_id, status="not_found", fill_price=0.0, + fill_quantity=0, instrument="", action="", exchange="dry_run", + ) + + def cancel_order(self, order_id: str) -> bool: + if order_id in self._orders: + self._orders[order_id].status = "cancelled" + return True + return False + + +class DeribitExecutor(BaseExecutor): + """Executes orders on Deribit via JSON-RPC 2.0 over POST. + Uses `contracts` parameter for unambiguous option sizing. + Converts USD prices to BTC using index price, snaps to order book, + and aligns to tick size (0.0005 BTC). + Retries on transient errors (429, 502, 503, timeout).""" + + TICK_SIZE = 0.0005 # Deribit option price tick size in BTC + + def __init__(self, client_id: str, client_secret: str, testnet: bool = False): + self.client_id = client_id + self.client_secret = client_secret + self.base_url = ( + "https://test.deribit.com/api/v2" + if testnet + else "https://www.deribit.com/api/v2" + ) + self.token: str | None = None + self._rpc_id = 0 + self._index_cache: dict[str, float] = {} # asset -> USD index price + + def _next_id(self) -> int: + self._rpc_id += 1 + return self._rpc_id + + def _rpc(self, method: str, params: dict) -> dict: + """Send a JSON-RPC 2.0 POST request with retry on transient errors.""" + payload = { + "jsonrpc": "2.0", + "id": self._next_id(), + "method": method, + "params": params, + } + headers = {"Content-Type": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + + def _call(): + resp = requests.post(self.base_url, json=payload, headers=headers, timeout=10) + resp.raise_for_status() + data = resp.json() + if "error" in data: + raise RuntimeError(data["error"].get("message", str(data["error"]))) + return data.get("result", {}) + + return _retry(_call) + + def _get_index_price(self, asset: str) -> float: + """Fetch the USD index price for an asset (e.g. BTC-USD). + Cached per session to avoid repeated calls.""" + if asset in self._index_cache: + return self._index_cache[asset] + try: + index_name = f"{asset.lower()}_usd" + result = self._rpc("public/get_index_price", {"index_name": index_name}) + price = float(result.get("index_price", 0)) + if price > 0: + self._index_cache[asset] = price + return price + except Exception: + return 0.0 + + def _get_book_price(self, instrument: str, action: str) -> float | None: + """Fetch live best bid/ask from Deribit order book. + Returns best ask for BUY, best bid for SELL. + Falls back to mark_price when best bid/ask is unavailable.""" + try: + result = self._rpc("public/get_order_book", { + "instrument_name": instrument, "depth": 1, + }) + if action == "BUY": + price = float(result.get("best_ask_price", 0)) + else: + price = float(result.get("best_bid_price", 0)) + if price and price > 0: + return price + # Fallback to mark_price when book is empty + mark = float(result.get("mark_price", 0)) + return mark if mark > 0 else None + except Exception: + return None + + @staticmethod + def _align_tick(price: float, tick_size: float = 0.0005) -> float: + """Round a BTC price to the nearest tick size.""" + if tick_size <= 0: + return price + return round(round(price / tick_size) * tick_size, 10) + + def _usd_to_btc(self, price_usd: float, asset: str) -> float: + """Convert a USD option price to BTC using the index price. + Falls back to returning the USD price if index is unavailable.""" + index = self._get_index_price(asset) + if index <= 0: + return price_usd + return self._align_tick(price_usd / index) + + def authenticate(self) -> bool: + if self.token: + return True + try: + result = self._rpc("public/auth", { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }) + self.token = result.get("access_token") + return self.token is not None + except Exception: + return False + + def place_order(self, order: OrderRequest) -> OrderResult: + t0 = time.monotonic() + ts = _now_iso() + # Convert USD limit price to BTC for Deribit + asset = order.instrument.split("-")[0] if "-" in order.instrument else "" + price_btc = self._usd_to_btc(order.price, asset) if asset else order.price + # Snap to live order book if available (tighter price) + book_price = self._get_book_price(order.instrument, order.action) + if book_price is not None and book_price > 0: + if order.action == "BUY": + price_btc = min(price_btc, book_price) if price_btc > 0 else book_price + else: + price_btc = max(price_btc, book_price) + method = "private/buy" if order.action == "BUY" else "private/sell" + params = { + "instrument_name": order.instrument, + "contracts": order.quantity, + "type": order.order_type, + } + if order.order_type == "limit": + params["price"] = price_btc + try: + result = self._rpc(method, params) + latency = (time.monotonic() - t0) * 1000 + order_data = result.get("order", {}) + fill_price = float(order_data.get("average_price", 0)) + slippage = _compute_slippage(order.price, fill_price, order.action) + return OrderResult( + order_id=order_data.get("order_id", ""), + status=order_data.get("order_state", "error"), + fill_price=fill_price, + fill_quantity=int(order_data.get("filled_amount", 0)), + instrument=order.instrument, + action=order.action, + exchange="deribit", + slippage_pct=slippage, + timestamp=ts, + latency_ms=round(latency, 2), + ) + except Exception as e: + latency = (time.monotonic() - t0) * 1000 + return OrderResult( + order_id="", status="error", fill_price=0.0, + fill_quantity=0, instrument=order.instrument, + action=order.action, exchange="deribit", error=str(e), + timestamp=ts, latency_ms=round(latency, 2), + ) + + def get_order_status(self, order_id: str) -> OrderResult: + try: + result = self._rpc("private/get_order_state", {"order_id": order_id}) + return OrderResult( + order_id=order_id, + status=result.get("order_state", "unknown"), + fill_price=float(result.get("average_price", 0)), + fill_quantity=int(result.get("filled_amount", 0)), + instrument=result.get("instrument_name", ""), + action="BUY" if result.get("direction") == "buy" else "SELL", + exchange="deribit", + ) + except Exception: + return OrderResult( + order_id=order_id, status="unknown", fill_price=0.0, + fill_quantity=0, instrument="", action="", exchange="deribit", + ) + + def cancel_order(self, order_id: str) -> bool: + try: + self._rpc("private/cancel", {"order_id": order_id}) + return True + except Exception: + return False + + +class AevoExecutor(BaseExecutor): + """Executes orders on Aevo via REST API with EIP-712 L2 signing. + Aevo is an OP Stack L2 — every order must carry an EIP-712 signature + signed by the trading key. The REST HMAC headers authenticate the + request; the payload signature authorises the on-chain settlement. + Retries on transient errors (429, 502, 503, timeout).""" + + # EIP-712 domain separators (per Aevo SDK) + _DOMAIN_MAINNET = {"name": "Aevo Mainnet", "version": "1", "chainId": 1} + _DOMAIN_TESTNET = {"name": "Aevo Testnet", "version": "1", "chainId": 11155111} + + # EIP-712 Order struct type definition + _ORDER_TYPES = { + "Order": [ + {"name": "maker", "type": "address"}, + {"name": "isBuy", "type": "bool"}, + {"name": "limitPrice", "type": "uint256"}, + {"name": "amount", "type": "uint256"}, + {"name": "salt", "type": "uint256"}, + {"name": "instrument", "type": "uint256"}, + {"name": "timestamp", "type": "uint256"}, + ], + } + + def __init__(self, api_key: str, api_secret: str, + signing_key: str, wallet_address: str, + testnet: bool = False): + if not _HAS_ETH_ACCOUNT: + raise ImportError( + "eth-account is required for Aevo L2 signing. " + "Install it: pip install eth-account" + ) + self.api_key = api_key + self.api_secret = api_secret + self.signing_key = signing_key + self.wallet_address = wallet_address + self.testnet = testnet + self.base_url = ( + "https://api-testnet.aevo.xyz" + if testnet + else "https://api.aevo.xyz" + ) + self._domain = self._DOMAIN_TESTNET if testnet else self._DOMAIN_MAINNET + # Cache: human-readable instrument name → numeric instrument ID + self._instrument_cache: dict[str, int] = {} + + def _sign_hmac(self, timestamp: str, method: str, path: str, body: str) -> str: + """HMAC-SHA256 for REST API authentication headers.""" + message = f"{timestamp}{method}{path}{body}" + return hmac.new( + self.api_secret.encode(), message.encode(), hashlib.sha256, + ).hexdigest() + + def _headers(self, method: str = "GET", path: str = "/", body: str = "") -> dict: + ts = str(int(time.time())) + return { + "AEVO-KEY": self.api_key, + "AEVO-TIMESTAMP": ts, + "AEVO-SIGNATURE": self._sign_hmac(ts, method, path, body), + "Content-Type": "application/json", + } + + def _sign_order(self, instrument_id: int, is_buy: bool, + limit_price: float, quantity: int, + timestamp: int) -> tuple[int, str]: + """EIP-712 L2 order signing. Returns (salt, signature_hex).""" + salt = random.randint(0, 10**10) + message_data = { + "maker": self.wallet_address, + "isBuy": is_buy, + "limitPrice": int(limit_price * 10**6), + "amount": int(quantity * 10**6), + "salt": salt, + "instrument": instrument_id, + "timestamp": timestamp, + } + signed = _EthAccount.sign_typed_data( + self.signing_key, + domain_data=self._domain, + message_types=self._ORDER_TYPES, + message_data=message_data, + ) + return salt, "0x" + signed.signature.hex() + + def _resolve_instrument_id(self, instrument_name: str) -> int: + """Look up numeric instrument ID from Aevo /markets endpoint. + Caches results for the session.""" + if instrument_name in self._instrument_cache: + return self._instrument_cache[instrument_name] + try: + resp = requests.get( + f"{self.base_url}/markets", + headers=self._headers("GET", "/markets"), + params={"instrument_name": instrument_name}, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + # Response can be a list of markets or a single market + if isinstance(data, list): + for market in data: + name = market.get("instrument_name", "") + mid = int(market.get("instrument_id", 0)) + self._instrument_cache[name] = mid + if instrument_name in self._instrument_cache: + return self._instrument_cache[instrument_name] + elif isinstance(data, dict): + mid = int(data.get("instrument_id", 0)) + self._instrument_cache[instrument_name] = mid + return mid + except Exception: + pass + raise ValueError( + f"Could not resolve Aevo instrument ID for '{instrument_name}'. " + "Verify the instrument exists on Aevo." + ) + + def authenticate(self) -> bool: + return bool(self.api_key and self.api_secret + and self.signing_key and self.wallet_address) + + def place_order(self, order: OrderRequest) -> OrderResult: + t0 = time.monotonic() + ts = _now_iso() + try: + instrument_id = self._resolve_instrument_id(order.instrument) + is_buy = order.action == "BUY" + timestamp = int(time.time()) + salt, signature = self._sign_order( + instrument_id, is_buy, order.price, order.quantity, timestamp, + ) + payload = { + "maker": self.wallet_address, + "is_buy": is_buy, + "instrument": instrument_id, + "limit_price": str(int(order.price * 10**6)), + "amount": str(int(order.quantity * 10**6)), + "salt": str(salt), + "signature": signature, + "timestamp": timestamp, + } + if order.order_type == "limit": + payload["post_only"] = True + body = json.dumps(payload) + + def _call(): + resp = requests.post( + f"{self.base_url}/orders", + data=body, + headers=self._headers("POST", "/orders", body), + timeout=10, + ) + resp.raise_for_status() + return resp.json() + + data = _retry(_call) + latency = (time.monotonic() - t0) * 1000 + fill_price = float(data.get("avg_price", 0)) + slippage = _compute_slippage(order.price, fill_price, order.action) + return OrderResult( + order_id=data.get("order_id", ""), + status=data.get("status", "error"), + fill_price=fill_price, + fill_quantity=int(data.get("filled", 0)), + instrument=order.instrument, + action=order.action, + exchange="aevo", + slippage_pct=slippage, + timestamp=ts, + latency_ms=round(latency, 2), + ) + except Exception as e: + latency = (time.monotonic() - t0) * 1000 + return OrderResult( + order_id="", status="error", fill_price=0.0, + fill_quantity=0, instrument=order.instrument, + action=order.action, exchange="aevo", error=str(e), + timestamp=ts, latency_ms=round(latency, 2), + ) + + def get_order_status(self, order_id: str) -> OrderResult: + try: + resp = requests.get( + f"{self.base_url}/orders/{order_id}", + headers=self._headers("GET", f"/orders/{order_id}"), + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + return OrderResult( + order_id=order_id, + status=data.get("status", "unknown"), + fill_price=float(data.get("avg_price", 0)), + fill_quantity=int(data.get("filled", 0)), + instrument=data.get("instrument_name", ""), + action="BUY" if data.get("is_buy") else "SELL", + exchange="aevo", + ) + except Exception: + return OrderResult( + order_id=order_id, status="unknown", fill_price=0.0, + fill_quantity=0, instrument="", action="", exchange="aevo", + ) + + def cancel_order(self, order_id: str) -> bool: + try: + resp = requests.delete( + f"{self.base_url}/orders/{order_id}", + headers=self._headers("DELETE", f"/orders/{order_id}"), + timeout=10, + ) + resp.raise_for_status() + return True + except Exception: + return False + + +# --- Slippage --- + +def _compute_slippage(limit_price: float, fill_price: float, action: str) -> float: + """Compute slippage as a percentage. Positive = worse than limit.""" + if limit_price <= 0: + return 0.0 + if action == "BUY": + return ((fill_price - limit_price) / limit_price) * 100 + else: + return ((limit_price - fill_price) / limit_price) * 100 + + +def check_slippage(result: OrderResult, max_slippage_pct: float) -> bool: + """Return True if slippage is within acceptable bounds.""" + return result.slippage_pct <= max_slippage_pct + + +# --- Order monitoring --- + +def _monitor_order(executor: BaseExecutor, order_id: str, + timeout_seconds: float = 30.0, + poll_interval: float = 1.0) -> OrderResult: + """Poll order status until terminal state or timeout. + Returns the final OrderResult. On timeout, attempts to cancel the order.""" + _terminal = ("filled", "rejected", "cancelled", "error", "simulated") + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + result = executor.get_order_status(order_id) + if result.status in _terminal: + return result + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(poll_interval, max(0, remaining))) + # Timeout: attempt cancel + executor.cancel_order(order_id) + return OrderResult( + order_id=order_id, status="timeout", fill_price=0.0, + fill_quantity=0, instrument="", action="", exchange="", + ) + + +def _cancel_filled_orders(results: list[OrderResult], + get_exec_fn) -> list[str]: + """Cancel already-filled orders when a later leg fails. + get_exec_fn(exchange) -> BaseExecutor for the right exchange. + Returns list of cancelled order IDs.""" + cancelled = [] + for r in results: + if r.status in ("filled",) and r.order_id: + ex = get_exec_fn(r.exchange) + if ex and ex.cancel_order(r.order_id): + cancelled.append(r.order_id) + return cancelled + + +# --- Orchestration --- + +def build_execution_plan(scored, asset: str, exchange: str | None, + exchange_quotes: list, synth_options: dict, + size_multiplier: int = 1) -> ExecutionPlan: + """Build an ExecutionPlan from a ScoredStrategy. + When exchange is None, auto-routes each leg to the best exchange via leg_divergences. + size_multiplier scales all leg quantities (default 1).""" + strategy = scored.strategy + plan = ExecutionPlan( + strategy_description=strategy.description, + strategy_type=strategy.strategy_type, + exchange=exchange or "auto", + asset=asset, + expiry=strategy.expiry or "", + dry_run=False, + ) + + # Get per-leg routing when auto-routing + leg_routes = {} + if exchange is None: + leg_routes = leg_divergences(strategy, exchange_quotes, synth_options) + + for i, leg in enumerate(strategy.legs): + # Determine exchange for this leg + if exchange is not None: + leg_exchange = exchange + elif i in leg_routes: + leg_exchange = leg_routes[i]["best_exchange"] + else: + quote = best_execution_price( + exchange_quotes, leg.strike, leg.option_type.lower(), leg.action, + ) + leg_exchange = quote.exchange if quote else "deribit" + + # Build instrument name + if leg_exchange == "aevo": + instrument = aevo_instrument_name(asset, leg.strike, leg.option_type) + else: + instrument = deribit_instrument_name( + asset, strategy.expiry or "", leg.strike, leg.option_type, + ) + + # Get execution price + quote = best_execution_price( + exchange_quotes, leg.strike, leg.option_type.lower(), leg.action, + ) + if quote is not None: + price = quote.ask if leg.action == "BUY" else quote.bid + else: + price = leg.premium + + qty = leg.quantity * max(1, size_multiplier) + plan.orders.append(OrderRequest( + instrument=instrument, + action=leg.action, + quantity=qty, + order_type="limit", + price=price, + exchange=leg_exchange, + leg_index=i, + strike=leg.strike, + option_type=leg.option_type.lower(), + )) + + buy_total = sum(o.price * o.quantity for o in plan.orders if o.action == "BUY") + sell_total = sum(o.price * o.quantity for o in plan.orders if o.action == "SELL") + plan.estimated_cost = buy_total - sell_total + plan.estimated_max_loss = strategy.max_loss * max(1, size_multiplier) + + return plan + + +def validate_plan(plan: ExecutionPlan, max_loss_budget: float | None = None) -> tuple[bool, str]: + """Validate an execution plan before submission. + max_loss_budget: if set, reject plans whose max loss exceeds this USD amount. + Returns (is_valid, error_message).""" + if not plan.orders: + return False, "No orders in plan" + for i, order in enumerate(plan.orders): + if not order.instrument: + return False, f"Order {i}: empty instrument name" + if order.price <= 0: + return False, f"Order {i}: price must be > 0 (got {order.price})" + if order.quantity <= 0: + return False, f"Order {i}: quantity must be > 0 (got {order.quantity})" + if order.action not in ("BUY", "SELL"): + return False, f"Order {i}: invalid action '{order.action}'" + if max_loss_budget is not None and plan.estimated_max_loss > max_loss_budget: + return False, ( + f"Max loss ${plan.estimated_max_loss:,.2f} exceeds budget " + f"${max_loss_budget:,.2f}" + ) + return True, "" + + +def execute_plan(plan: ExecutionPlan, executor, + max_slippage_pct: float | None = None, + timeout_seconds: float | None = None) -> ExecutionReport: + """Execute all orders in a plan sequentially. + executor: a BaseExecutor instance, or a callable(exchange_name) -> BaseExecutor + for auto-routing across multiple exchanges. + max_slippage_pct: if set, halt execution if any fill exceeds this slippage. + timeout_seconds: if set, monitor open orders until filled or timeout. + On partial failure, auto-cancels already-filled legs.""" + report = ExecutionReport(plan=plan, started_at=_now_iso()) + + is_factory = callable(executor) and not isinstance(executor, BaseExecutor) + _cached: dict[str, BaseExecutor] = {} + + def _get_exec(exchange: str) -> BaseExecutor | None: + if not is_factory: + return executor + if exchange not in _cached: + ex = executor(exchange) + if not ex.authenticate(): + return None + _cached[exchange] = ex + return _cached[exchange] + + if not is_factory: + if not executor.authenticate(): + report.summary = "Authentication failed" + report.finished_at = _now_iso() + return report + + use_timeout = timeout_seconds is not None and timeout_seconds > 0 + + for order in plan.orders: + ex = _get_exec(order.exchange) + if ex is None: + report.summary = f"Authentication failed for {order.exchange}" + report.all_filled = False + report.net_cost = _compute_net_cost(report.results) + report.finished_at = _now_iso() + return report + result = ex.place_order(order) + + # Monitor open orders until filled or timeout + if (use_timeout and result.status == "open" and result.order_id): + monitored = _monitor_order( + ex, result.order_id, + timeout_seconds=timeout_seconds, + poll_interval=1.0, + ) + result.status = monitored.status + if monitored.fill_price > 0: + result.fill_price = monitored.fill_price + result.slippage_pct = _compute_slippage( + order.price, monitored.fill_price, order.action, + ) + if monitored.fill_quantity > 0: + result.fill_quantity = monitored.fill_quantity + + report.results.append(result) + + # Slippage check + if (max_slippage_pct is not None + and result.status in ("filled", "simulated") + and not check_slippage(result, max_slippage_pct)): + # Auto-cancel already-filled legs + report.cancelled_orders = _cancel_filled_orders( + report.results, _get_exec, + ) + filled_legs = [r for r in report.results if r.status in ("filled", "simulated")] + instruments = ", ".join(r.instrument for r in filled_legs) + report.summary = ( + f"Slippage exceeded on {result.instrument}: " + f"{result.slippage_pct:.2f}% > {max_slippage_pct:.2f}% limit. " + f"Halted. Filled legs: {instruments}" + ) + report.all_filled = False + report.net_cost = _compute_net_cost(report.results) + report.slippage_total = sum( + r.slippage_pct for r in report.results if r.status in ("filled", "simulated") + ) + report.finished_at = _now_iso() + return report + + if result.status in ("error", "rejected", "timeout"): + # Auto-cancel already-filled legs + filled_legs = [r for r in report.results if r.status in ("filled", "simulated")] + if filled_legs: + report.cancelled_orders = _cancel_filled_orders( + filled_legs, _get_exec, + ) + instruments = ", ".join(r.instrument for r in filled_legs) + cancel_note = "" + if report.cancelled_orders: + cancel_note = f" Cancelled {len(report.cancelled_orders)} filled leg(s)." + report.summary = ( + f"Partial fill — order {order.leg_index} failed: " + f"{result.error or result.status}.{cancel_note} " + f"Filled legs: {instruments}" + ) + else: + report.summary = ( + f"Order {order.leg_index} failed: {result.error or result.status}" + ) + report.all_filled = False + report.net_cost = _compute_net_cost(report.results) + report.finished_at = _now_iso() + return report + + report.all_filled = all( + r.status in ("filled", "simulated") for r in report.results + ) + report.net_cost = _compute_net_cost(report.results) + report.slippage_total = sum( + r.slippage_pct for r in report.results if r.status in ("filled", "simulated") + ) + + if report.all_filled: + mode = "simulated" if plan.dry_run else "live" + report.summary = ( + f"All {len(report.results)} legs {mode} successfully. " + f"Net cost: ${report.net_cost:,.2f}" + ) + else: + report.summary = f"Execution completed with {len(report.results)} orders" + + report.finished_at = _now_iso() + return report + + +def _compute_net_cost(results: list[OrderResult]) -> float: + """Net cost = sum(buy fills) - sum(sell fills).""" + cost = 0.0 + for r in results: + if r.status in ("filled", "simulated"): + if r.action == "BUY": + cost += r.fill_price * r.fill_quantity + else: + cost -= r.fill_price * r.fill_quantity + return cost + + +def compute_execution_savings(plan: ExecutionPlan, synth_options: dict) -> dict: + """Compare auto-routed execution cost against Synth theoretical price. + Returns savings breakdown showing the edge captured by venue selection.""" + synth_cost = 0.0 + exec_cost = 0.0 + for order in plan.orders: + # Synth theoretical price from option pricing data + opt_key = "call_options" if order.option_type == "call" else "put_options" + opts = synth_options.get(opt_key, {}) + synth_price = opts.get(str(int(order.strike)), order.price) + if order.action == "BUY": + synth_cost += synth_price * order.quantity + exec_cost += order.price * order.quantity + else: + synth_cost -= synth_price * order.quantity + exec_cost -= order.price * order.quantity + savings = synth_cost - exec_cost + return { + "synth_theoretical_cost": round(synth_cost, 2), + "execution_cost": round(exec_cost, 2), + "savings_usd": round(savings, 2), + "savings_pct": round((savings / synth_cost * 100) if synth_cost != 0 else 0, 2), + } + + +def save_execution_log(report: ExecutionReport, filepath: str) -> None: + """Save execution report as JSON for audit trail.""" + log = { + "timestamp": _now_iso(), + "started_at": report.started_at, + "finished_at": report.finished_at, + "strategy": report.plan.strategy_description, + "strategy_type": report.plan.strategy_type, + "asset": report.plan.asset, + "exchange": report.plan.exchange, + "mode": "dry_run" if report.plan.dry_run else "live", + "all_filled": report.all_filled, + "net_cost": round(report.net_cost, 2), + "slippage_total_pct": round(report.slippage_total, 4), + "summary": report.summary, + "cancelled_orders": report.cancelled_orders, + "orders": [ + { + "instrument": o.instrument, + "action": o.action, + "quantity": o.quantity, + "order_type": o.order_type, + "limit_price": round(o.price, 2), + "exchange": o.exchange, + } + for o in report.plan.orders + ], + "fills": [ + { + "order_id": r.order_id, + "instrument": r.instrument, + "action": r.action, + "status": r.status, + "fill_price": round(r.fill_price, 2), + "fill_quantity": r.fill_quantity, + "exchange": r.exchange, + "slippage_pct": round(r.slippage_pct, 4), + "timestamp": r.timestamp, + "latency_ms": r.latency_ms, + "error": r.error, + } + for r in report.results + ], + } + with open(filepath, "w") as f: + json.dump(log, f, indent=2, ensure_ascii=False) + + +def get_executor(exchange: str | None, exchange_quotes: list, + dry_run: bool = False): + """Factory: return the appropriate executor. + dry_run=True always returns DryRunExecutor. + exchange=None with dry_run=False returns a callable factory for per-leg routing. + Otherwise reads credentials from environment variables.""" + if dry_run: + return DryRunExecutor(exchange_quotes) + + if exchange is None: + def _executor_factory(ex: str): + return get_executor(ex, exchange_quotes, dry_run=False) + return _executor_factory + + if exchange == "deribit": + client_id = os.environ.get("DERIBIT_CLIENT_ID", "") + client_secret = os.environ.get("DERIBIT_CLIENT_SECRET", "") + if not client_id or not client_secret: + raise ValueError( + "Deribit credentials required: set DERIBIT_CLIENT_ID and " + "DERIBIT_CLIENT_SECRET environment variables" + ) + testnet = os.environ.get("DERIBIT_TESTNET", "").strip() == "1" + return DeribitExecutor(client_id, client_secret, testnet) + + if exchange == "aevo": + api_key = os.environ.get("AEVO_API_KEY", "") + api_secret = os.environ.get("AEVO_API_SECRET", "") + signing_key = os.environ.get("AEVO_SIGNING_KEY", "") + wallet_address = os.environ.get("AEVO_WALLET_ADDRESS", "") + if not api_key or not api_secret: + raise ValueError( + "Aevo credentials required: set AEVO_API_KEY and " + "AEVO_API_SECRET environment variables" + ) + if not signing_key or not wallet_address: + raise ValueError( + "Aevo L2 signing credentials required: set AEVO_SIGNING_KEY " + "(Ethereum private key) and AEVO_WALLET_ADDRESS environment variables. " + "Generate a signing key at https://app.aevo.xyz → Enable Trading." + ) + testnet = os.environ.get("AEVO_TESTNET", "").strip() == "1" + return AevoExecutor(api_key, api_secret, signing_key, wallet_address, testnet) + + raise ValueError( + f"Unknown exchange '{exchange}'. Use --exchange deribit or --exchange aevo" + ) diff --git a/tools/options-gps/main.py b/tools/options-gps/main.py index 6fd9936..0acbf6e 100644 --- a/tools/options-gps/main.py +++ b/tools/options-gps/main.py @@ -41,6 +41,15 @@ compute_edge, ) +from executor import ( + build_execution_plan, + validate_plan, + execute_plan, + get_executor, + save_execution_log, + compute_execution_savings, +) + SUPPORTED_ASSETS = ["BTC", "ETH", "SOL", "XAU", "SPY", "NVDA", "TSLA", "AAPL", "GOOGL"] @@ -671,6 +680,99 @@ def screen_if_wrong(best: ScoredStrategy | None, no_trade_reason: str | None, print(_footer()) +def screen_execution(card: ScoredStrategy, asset: str, exchange: str | None, + exchange_quotes: list, synth_options: dict, dry_run: bool = False, + no_prompt: bool = False, size: int = 1, + max_slippage: float | None = None, + max_loss: float | None = None, + log_file: str | None = None, + timeout: float | None = None): + """Screen 5: Execution — build plan, confirm, execute, report results. + Returns ExecutionReport or None if cancelled/failed.""" + print(_header("Screen 5: Execution")) + mode_label = "DRY RUN" if dry_run else "LIVE" + print(f"{BAR} Mode: {mode_label}") + + plan = build_execution_plan(card, asset, exchange, exchange_quotes, synth_options, + size_multiplier=size) + plan.dry_run = dry_run + + valid, err = validate_plan(plan, max_loss_budget=max_loss) + if not valid: + print(f"{BAR} Pre-flight FAILED: {err}") + print(_footer()) + return None + + print(f"{BAR} Exchange: {plan.exchange.upper()}") + print(f"{BAR} Asset: {plan.asset}") + print(f"{BAR} Strategy: {plan.strategy_description}") + if size > 1: + print(f"{BAR} Size: {size}x (multiplied)") + print(f"{BAR}") + print(_section("ORDER PLAN")) + for order in plan.orders: + print(f"{BAR} Leg {order.leg_index}: {order.action} {order.quantity}x " + f"{order.instrument} @ ${order.price:,.2f} ({order.order_type}) " + f"[{order.exchange}]") + print(f"{BAR}") + print(_kv("Est. Cost", f"${plan.estimated_cost:,.2f}")) + print(_kv("Est. Max Loss", f"${plan.estimated_max_loss:,.2f}")) + if max_slippage is not None: + print(_kv("Max Slippage", f"{max_slippage:.2f}%")) + if max_loss is not None: + print(_kv("Max Loss Budget", f"${max_loss:,.2f}")) + + if not dry_run: + print(f"{BAR}") + print(f"{BAR} WARNING: This will submit LIVE orders.") + _pause("confirm execution", no_prompt) + + try: + executor = get_executor(exchange, exchange_quotes, dry_run) + except ValueError as e: + print(f"{BAR} {e}") + print(_footer()) + return None + + report = execute_plan(plan, executor, max_slippage_pct=max_slippage, + timeout_seconds=timeout) + + print(f"{BAR}") + print(_section("RESULTS")) + for result in report.results: + status_icon = "\u2713" if result.status in ("filled", "simulated") else "\u2717" + slip_str = f" [slip {result.slippage_pct:+.2f}%]" if result.slippage_pct != 0 else "" + latency_str = f" [{result.latency_ms:.0f}ms]" if result.latency_ms > 0 else "" + print(f"{BAR} {status_icon} {result.action} {result.instrument}: " + f"{result.status} @ ${result.fill_price:,.2f} x{result.fill_quantity}" + f"{slip_str}{latency_str}" + + (f" [{result.error}]" if result.error else "")) + print(f"{BAR}") + print(_kv("All Filled", "Yes" if report.all_filled else "No")) + print(_kv("Net Cost", f"${report.net_cost:,.2f}")) + if report.slippage_total != 0: + print(_kv("Total Slippage", f"{report.slippage_total:.2f}%")) + if report.started_at and report.finished_at: + print(_kv("Started", report.started_at)) + print(_kv("Finished", report.finished_at)) + if report.cancelled_orders: + print(_kv("Cancelled", f"{len(report.cancelled_orders)} order(s)")) + # Execution savings vs Synth theoretical + if report.all_filled and plan.exchange == "auto": + savings = compute_execution_savings(plan, synth_options) + if savings["savings_usd"] != 0: + sign = "+" if savings["savings_usd"] > 0 else "" + print(_kv("Savings vs Synth", f"{sign}${savings['savings_usd']:,.2f} ({sign}{savings['savings_pct']:.1f}%)")) + print(f"{BAR} {report.summary}") + print(_footer()) + + if log_file and report is not None: + save_execution_log(report, log_file) + print(f" Execution log saved to {log_file}") + + return report + + def _card_to_log(card: ScoredStrategy | None, exchange_divergence: float | None = None) -> dict | None: """Serialize a strategy card for the decision log with full trade construction.""" if card is None: @@ -706,9 +808,13 @@ def _card_to_log(card: ScoredStrategy | None, exchange_divergence: float | None def _parse_screen_arg(screen_arg: str) -> set[int]: - """Parse --screen flag into set of screen numbers (1-4).""" - if screen_arg.strip().lower() == "all": + """Parse --screen flag into set of screen numbers (1-4). + Supports 'all' (default), 'none'/'0' (skip all analysis), or comma-separated.""" + val = screen_arg.strip().lower() + if val == "all": return {1, 2, 3, 4} + if val in ("none", "0"): + return set() screens: set[int] = set() for part in screen_arg.split(","): part = part.strip() @@ -717,6 +823,14 @@ def _parse_screen_arg(screen_arg: str) -> set[int]: return screens or {1, 2, 3, 4} +def _refuse_execution(is_live: bool, no_trade_reason: str | None, force: bool) -> str | None: + """Check if execution should be refused due to guardrails. + Returns refusal message string, or None if execution is allowed.""" + if is_live and no_trade_reason and not force: + return f"Guardrail: {no_trade_reason}. Use --force to override." + return None + + def main(): parser = argparse.ArgumentParser( description="Options GPS: turn a market view into one clear options decision", @@ -725,9 +839,29 @@ def main(): parser.add_argument("--view", default=None, choices=["bullish", "bearish", "neutral", "vol"]) parser.add_argument("--risk", default=None, choices=["low", "medium", "high"]) parser.add_argument("--screen", default="all", - help="Screens to show: comma-separated 1,2,3,4 or 'all' (default: all)") + help="Screens to show: 1,2,3,4 / 'all' / 'none' (default: all)") parser.add_argument("--no-prompt", action="store_true", dest="no_prompt", help="Skip pause between screens (dump all at once)") + parser.add_argument("--execute", default=None, nargs="?", const="best", + choices=["best", "safer", "upside"], + help="Execute a strategy on a live exchange (default: best)") + parser.add_argument("--dry-run", default=None, nargs="?", const="best", + choices=["best", "safer", "upside"], + help="Simulate execution (default: best)") + parser.add_argument("--exchange", default=None, choices=["deribit", "aevo"], + help="Force exchange (default: auto-route per leg)") + parser.add_argument("--force", action="store_true", + help="Override no-trade guardrail for live execution") + parser.add_argument("--size", type=int, default=1, + help="Position size multiplier (default: 1)") + parser.add_argument("--max-slippage", type=float, default=None, dest="max_slippage", + help="Max acceptable slippage %% (halt if exceeded)") + parser.add_argument("--max-loss", type=float, default=None, dest="max_loss", + help="Max loss budget in USD (reject if strategy exceeds)") + parser.add_argument("--log-file", default=None, dest="log_file", + help="Save execution report JSON to file") + parser.add_argument("--timeout", type=float, default=None, + help="Order monitoring timeout in seconds (default: 30)") args = parser.parse_args() screens = _parse_screen_arg(args.screen) with warnings.catch_warnings(): @@ -806,6 +940,44 @@ def main(): _pause("Screen 4: If Wrong", args.no_prompt) screen_if_wrong(best, no_trade_reason, outcome_prices, current_price, asset=symbol) shown_any = True + # Screen 5: Execution (triggered by --execute or --dry-run, not by --screen) + execution_report = None + exec_card_name = args.execute or args.dry_run # "best" | "safer" | "upside" | None + is_executing = exec_card_name is not None + if is_executing: + # Non-crypto guard + if symbol not in ("BTC", "ETH", "SOL"): + print(f"\n Execution only supported for crypto assets (BTC, ETH, SOL).") + sys.exit(1) + # Select card + card_map = {"best": best, "safer": safer, "upside": upside} + exec_card = card_map.get(exec_card_name) + is_live = args.execute is not None + is_dry = args.dry_run is not None + + if is_executing and exec_card is not None and exchange_quotes: + # Guardrail: refuse live execution when no-trade unless --force + refusal = _refuse_execution(is_live, no_trade_reason, args.force) + if refusal: + print(f"\n {refusal}") + else: + if shown_any: + _pause("Screen 5: Execution", args.no_prompt) + execution_report = screen_execution( + exec_card, symbol, args.exchange, exchange_quotes, options, + dry_run=is_dry or not is_live, + no_prompt=args.no_prompt, + size=args.size, + max_slippage=args.max_slippage, + max_loss=args.max_loss, + log_file=args.log_file, + timeout=args.timeout, + ) + shown_any = True + elif is_executing and exec_card is None: + print(f"\n Cannot execute '{exec_card_name}': no {exec_card_name} strategy available.") + elif is_executing and not exchange_quotes: + print(f"\n Cannot execute: exchange data not available.") if shown_any: _pause("Decision Log", args.no_prompt) decision_log = { @@ -829,6 +1001,29 @@ def main(): "safer_alt": _card_to_log(safer, divergence_by_strategy.get(id(safer.strategy)) if divergence_by_strategy and safer else None), "higher_upside": _card_to_log(upside, divergence_by_strategy.get(id(upside.strategy)) if divergence_by_strategy and upside else None), } + if execution_report is not None: + decision_log["execution"] = { + "mode": "dry_run" if execution_report.plan.dry_run else "live", + "exchange": execution_report.plan.exchange, + "all_filled": execution_report.all_filled, + "net_cost": round(execution_report.net_cost, 2), + "started_at": execution_report.started_at, + "finished_at": execution_report.finished_at, + "cancelled_orders": execution_report.cancelled_orders, + "fills": [ + { + "instrument": r.instrument, + "action": r.action, + "status": r.status, + "fill_price": round(r.fill_price, 2), + "fill_quantity": r.fill_quantity, + "slippage_pct": round(r.slippage_pct, 4), + "timestamp": r.timestamp, + "latency_ms": r.latency_ms, + } + for r in execution_report.results + ], + } print(_header("Decision Log (JSON)")) for line in json.dumps(decision_log, indent=2, ensure_ascii=False).split("\n"): print(f"{BAR} {line}") diff --git a/tools/options-gps/requirements.txt b/tools/options-gps/requirements.txt index 9a7fc84..681c8e0 100644 --- a/tools/options-gps/requirements.txt +++ b/tools/options-gps/requirements.txt @@ -2,3 +2,4 @@ # The synth_client package is included in the repo — no need to list it. # requests is needed if you want to use live API mode. requests>=2.28.0 +eth-account>=0.13.0 # EIP-712 L2 signing for Aevo order placement diff --git a/tools/options-gps/tests/test_executor.py b/tools/options-gps/tests/test_executor.py new file mode 100644 index 0000000..4804f57 --- /dev/null +++ b/tools/options-gps/tests/test_executor.py @@ -0,0 +1,619 @@ +"""Tests for executor.py: autonomous execution — instrument names, plan building, +validation, dry-run simulation, execution flow, and executor factory.""" + +import hashlib +import hmac as hmac_mod +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import requests +import pytest +from executor import ( + OrderRequest, + OrderResult, + ExecutionPlan, + BaseExecutor, + DryRunExecutor, + AevoExecutor, + DeribitExecutor, + deribit_instrument_name, + aevo_instrument_name, + build_execution_plan, + validate_plan, + execute_plan, + get_executor, + save_execution_log, + compute_execution_savings, + check_slippage, + _compute_slippage, + _is_retryable, + _monitor_order, + _cancel_filled_orders, +) +from exchange import _parse_instrument_key +from pipeline import ScoredStrategy + + +def _status_result(order_id: str, status: str) -> OrderResult: + """Helper: build a minimal OrderResult for test executors.""" + return OrderResult(order_id=order_id, status=status, fill_price=0.0, + fill_quantity=0, instrument="", action="", exchange="") + + +def _make_scored(strategy): + return ScoredStrategy( + strategy=strategy, probability_of_profit=0.55, expected_value=100.0, + tail_risk=50.0, loss_profile="premium at risk", + invalidation_trigger="Close on break", reroute_rule="Roll out", + review_again_at="Review at 50%", score=0.8, rationale="Test", + ) + + +# --- 1. Instrument Names (7 tests) --- + +class TestInstrumentNames: + def test_deribit_call(self): + assert deribit_instrument_name("BTC", "2026-02-26T08:00:00Z", 67500, "Call") == "BTC-26FEB26-67500-C" + + def test_deribit_put(self): + assert deribit_instrument_name("ETH", "2026-03-15T08:00:00Z", 4000, "Put") == "ETH-15MAR26-4000-P" + + def test_aevo_call(self): + assert aevo_instrument_name("BTC", 67500, "Call") == "BTC-67500-C" + + def test_aevo_put(self): + assert aevo_instrument_name("SOL", 150, "Put") == "SOL-150-P" + + def test_deribit_roundtrip(self): + name = deribit_instrument_name("BTC", "2026-02-26T08:00:00Z", 67500, "Call") + strike, opt_type = _parse_instrument_key(name) + assert strike == 67500 and opt_type == "call" + + def test_aevo_roundtrip(self): + name = aevo_instrument_name("BTC", 68000, "Put") + strike, opt_type = _parse_instrument_key(name) + assert strike == 68000 and opt_type == "put" + + def test_deribit_empty_expiry(self): + assert "UNKNOWN" in deribit_instrument_name("BTC", "", 67500, "Call") + + +# --- 2. Build Plan + Size Multiplier (8 tests) --- + +class TestBuildPlan: + def test_single_leg(self, sample_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + assert len(plan.orders) == 1 + o = plan.orders[0] + assert o.action == "BUY" and o.exchange == "deribit" + assert o.strike == 67500 and o.option_type == "call" + assert "67500" in o.instrument and plan.estimated_cost > 0 + + def test_multi_leg(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(multi_leg_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + assert len(plan.orders) == 2 + actions = {o.action for o in plan.orders} + assert actions == {"BUY", "SELL"} + + def test_aevo_names(self, sample_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "aevo", sample_exchange_quotes, btc_option_data) + assert plan.orders[0].instrument == "BTC-67500-C" + + def test_auto_route(self, sample_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", None, sample_exchange_quotes, btc_option_data) + assert plan.exchange == "auto" + assert plan.orders[0].exchange in ("deribit", "aevo") + + def test_estimated_cost(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(multi_leg_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + buy = sum(o.price * o.quantity for o in plan.orders if o.action == "BUY") + sell = sum(o.price * o.quantity for o in plan.orders if o.action == "SELL") + assert plan.estimated_cost == pytest.approx(buy - sell) + + def test_size_multiplier(self, sample_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data, size_multiplier=3) + assert plan.orders[0].quantity == 3 + + def test_size_scales_max_loss(self, sample_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(sample_strategy) + p1 = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data, size_multiplier=1) + p5 = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data, size_multiplier=5) + assert p5.estimated_max_loss == pytest.approx(p1.estimated_max_loss * 5) + + def test_exchange_override_all_legs(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(multi_leg_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + assert all(o.exchange == "deribit" for o in plan.orders) + + +# --- 3. Validate Plan + Max Loss Budget (7 tests) --- + +class TestValidatePlan: + def test_valid(self): + plan = ExecutionPlan( + strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-26FEB26-67500-C", "BUY", 1, "limit", 660.0, "deribit", 0, strike=67500, option_type="call")], + ) + assert validate_plan(plan) == (True, "") + + def test_empty_orders(self): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="") + valid, err = validate_plan(plan) + assert not valid and "No orders" in err + + def test_zero_price(self): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("X", "BUY", 1, "limit", 0.0, "deribit", 0, strike=67500, option_type="call")]) + assert not validate_plan(plan)[0] + + def test_zero_quantity(self): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("X", "BUY", 0, "limit", 660.0, "deribit", 0, strike=67500, option_type="call")]) + assert not validate_plan(plan)[0] + + def test_empty_instrument(self): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("", "BUY", 1, "limit", 660.0, "deribit", 0, strike=67500, option_type="call")]) + assert not validate_plan(plan)[0] + + def test_within_budget(self): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("X", "BUY", 1, "limit", 660.0, "deribit", 0, strike=67500, option_type="call")], + estimated_max_loss=500.0) + assert validate_plan(plan, max_loss_budget=1000.0)[0] + + def test_exceeds_budget(self): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("X", "BUY", 1, "limit", 660.0, "deribit", 0, strike=67500, option_type="call")], + estimated_max_loss=1500.0) + valid, err = validate_plan(plan, max_loss_budget=1000.0) + assert not valid and "budget" in err.lower() + + +# --- 4. DryRunExecutor — stateful, timestamps, OrderResult (10 tests) --- + +class TestDryRunExecutor: + def test_authenticate(self, sample_exchange_quotes): + assert DryRunExecutor(sample_exchange_quotes).authenticate() is True + + def test_place_buy(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + order = OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, strike=67500, option_type="call") + r = executor.place_order(order) + assert r.status == "simulated" and r.fill_price == 655.0 and r.fill_quantity == 1 + assert r.timestamp and "T" in r.timestamp # ISO 8601 + assert r.latency_ms >= 0 + + def test_place_sell(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + order = OrderRequest("BTC-67500-C", "SELL", 1, "limit", 620.0, "dry_run", 0, strike=67500, option_type="call") + r = executor.place_order(order) + assert r.status == "simulated" and r.fill_price == 620.0 + + def test_missing_strike_error(self, sample_exchange_quotes): + r = DryRunExecutor(sample_exchange_quotes).place_order( + OrderRequest("INVALID", "BUY", 1, "limit", 100.0, "dry_run", 0)) + assert r.status == "error" and r.timestamp + + def test_no_matching_quote_fallback(self, sample_exchange_quotes): + r = DryRunExecutor(sample_exchange_quotes).place_order( + OrderRequest("BTC-99999-C", "BUY", 1, "limit", 100.0, "dry_run", 0, strike=99999, option_type="call")) + assert r.status == "simulated" and r.fill_price == 100.0 + + def test_stateful_get_status(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + order = OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, strike=67500, option_type="call") + placed = executor.place_order(order) + result = executor.get_order_status(placed.order_id) + assert isinstance(result, OrderResult) + assert result.status == "simulated" and result.order_id == placed.order_id + + def test_unknown_id_not_found(self, sample_exchange_quotes): + r = DryRunExecutor(sample_exchange_quotes).get_order_status("nonexistent") + assert r.status == "not_found" + + def test_cancel_transitions_state(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + placed = executor.place_order( + OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, strike=67500, option_type="call")) + assert executor.cancel_order(placed.order_id) is True + assert executor.get_order_status(placed.order_id).status == "cancelled" + + def test_cancel_unknown_returns_false(self, sample_exchange_quotes): + assert DryRunExecutor(sample_exchange_quotes).cancel_order("nope") is False + + def test_tracks_multiple_orders(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + r1 = executor.place_order(OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, strike=67500, option_type="call")) + r2 = executor.place_order(OrderRequest("BTC-67500-P", "SELL", 1, "limit", 300.0, "dry_run", 1, strike=67500, option_type="put")) + assert r1.order_id != r2.order_id + assert executor.get_order_status(r1.order_id).status == "simulated" + assert executor.get_order_status(r2.order_id).status == "simulated" + + +# --- 5. Execute Flow + Timeout + Partial Fill (8 tests) --- + +class TestExecuteFlow: + def test_single_leg(self, sample_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + plan.dry_run = True + report = execute_plan(plan, DryRunExecutor(sample_exchange_quotes)) + assert report.all_filled and len(report.results) == 1 + assert report.results[0].status == "simulated" + assert report.started_at and report.finished_at + + def test_multi_leg(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(multi_leg_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + plan.dry_run = True + report = execute_plan(plan, DryRunExecutor(sample_exchange_quotes)) + assert report.all_filled and len(report.results) == 2 + + def test_net_cost(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(multi_leg_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + plan.dry_run = True + report = execute_plan(plan, DryRunExecutor(sample_exchange_quotes)) + buy = sum(r.fill_price * r.fill_quantity for r in report.results if r.action == "BUY") + sell = sum(r.fill_price * r.fill_quantity for r in report.results if r.action == "SELL") + assert report.net_cost == pytest.approx(buy - sell) + + def test_summary_message(self, sample_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + plan.dry_run = True + report = execute_plan(plan, DryRunExecutor(sample_exchange_quotes)) + assert "simulated" in report.summary and "Net cost" in report.summary + + def test_timeout_skips_simulated(self, sample_exchange_quotes): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 655.0, "dry_run", 0, strike=67500, option_type="call")], dry_run=True) + report = execute_plan(plan, DryRunExecutor(sample_exchange_quotes), timeout_seconds=5.0) + assert report.all_filled and report.results[0].status == "simulated" + + def test_timeout_triggers_for_open(self): + class OpenExecutor(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): + return OrderResult(order_id="open-1", status="open", fill_price=0.0, + fill_quantity=0, instrument=order.instrument, action=order.action, exchange="test") + def get_order_status(self, oid): return _status_result(oid, "open") + def cancel_order(self, oid): return True + + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="test", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 655.0, "test", 0, strike=67500, option_type="call")]) + report = execute_plan(plan, OpenExecutor(), timeout_seconds=0.1) + assert not report.all_filled and report.results[0].status == "timeout" + + def test_partial_fill_auto_cancels(self, sample_exchange_quotes): + call_count = {"n": 0} + class PartialExec(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): + call_count["n"] += 1 + if call_count["n"] == 1: + return OrderResult(order_id="fill-1", status="filled", fill_price=100.0, + fill_quantity=1, instrument=order.instrument, action=order.action, exchange="test") + return OrderResult(order_id="", status="error", fill_price=0.0, + fill_quantity=0, instrument=order.instrument, action=order.action, exchange="test", error="rejected") + def get_order_status(self, oid): return _status_result(oid, "filled") + def cancel_order(self, oid): return True + + plan = ExecutionPlan(strategy_description="T", strategy_type="call_debit_spread", exchange="test", asset="BTC", expiry="", + orders=[ + OrderRequest("BTC-67500-C", "BUY", 1, "limit", 655.0, "test", 0, strike=67500, option_type="call"), + OrderRequest("BTC-68000-C", "SELL", 1, "limit", 385.0, "test", 1, strike=68000, option_type="call"), + ]) + report = execute_plan(plan, PartialExec()) + assert not report.all_filled and "Partial fill" in report.summary + assert len(report.cancelled_orders) > 0 + + def test_factory_routes_per_leg(self, sample_exchange_quotes): + plan = ExecutionPlan(strategy_description="T", strategy_type="call_debit_spread", exchange="auto", asset="BTC", expiry="", + orders=[ + OrderRequest("BTC-67500-C", "BUY", 1, "limit", 655.0, "aevo", 0, strike=67500, option_type="call"), + OrderRequest("BTC-26FEB26-68000-C", "SELL", 1, "limit", 385.0, "deribit", 1, strike=68000, option_type="call"), + ], dry_run=True) + report = execute_plan(plan, lambda ex: DryRunExecutor(sample_exchange_quotes)) + assert report.all_filled and len(report.results) == 2 + + +# --- 6. Get Executor (7 tests) --- + +class TestGetExecutor: + def test_dry_run(self, sample_exchange_quotes): + assert isinstance(get_executor("deribit", sample_exchange_quotes, dry_run=True), DryRunExecutor) + + def test_dry_run_ignores_exchange(self, sample_exchange_quotes): + assert isinstance(get_executor("aevo", sample_exchange_quotes, dry_run=True), DryRunExecutor) + + def test_auto_route_dry_run(self, sample_exchange_quotes): + assert isinstance(get_executor(None, sample_exchange_quotes, dry_run=True), DryRunExecutor) + + def test_auto_route_returns_factory(self, sample_exchange_quotes): + result = get_executor(None, sample_exchange_quotes, dry_run=False) + assert callable(result) and not isinstance(result, DryRunExecutor) + + def test_missing_deribit_creds(self, sample_exchange_quotes, monkeypatch): + monkeypatch.delenv("DERIBIT_CLIENT_ID", raising=False) + monkeypatch.delenv("DERIBIT_CLIENT_SECRET", raising=False) + with pytest.raises(ValueError, match="DERIBIT_CLIENT_ID"): + get_executor("deribit", sample_exchange_quotes, dry_run=False) + + def test_missing_aevo_creds(self, sample_exchange_quotes, monkeypatch): + monkeypatch.delenv("AEVO_API_KEY", raising=False) + monkeypatch.delenv("AEVO_API_SECRET", raising=False) + monkeypatch.delenv("AEVO_SIGNING_KEY", raising=False) + monkeypatch.delenv("AEVO_WALLET_ADDRESS", raising=False) + with pytest.raises(ValueError, match="AEVO_API_KEY"): + get_executor("aevo", sample_exchange_quotes, dry_run=False) + + def test_unknown_exchange(self, sample_exchange_quotes): + with pytest.raises(ValueError, match="Unknown exchange"): + get_executor("binance", sample_exchange_quotes, dry_run=False) + + +# --- 7. Slippage (8 tests) --- + +class TestSlippage: + def test_buy_worse(self): + assert _compute_slippage(100.0, 102.0, "BUY") == pytest.approx(2.0) + + def test_buy_better(self): + assert _compute_slippage(100.0, 98.0, "BUY") == pytest.approx(-2.0) + + def test_sell_worse(self): + assert _compute_slippage(100.0, 98.0, "SELL") == pytest.approx(2.0) + + def test_zero_limit(self): + assert _compute_slippage(0.0, 100.0, "BUY") == 0.0 + + def test_check_within(self): + r = OrderResult("id", "filled", 102.0, 1, "X", "BUY", "test", slippage_pct=1.5) + assert check_slippage(r, 2.0) is True + + def test_check_exceeded(self): + r = OrderResult("id", "filled", 105.0, 1, "X", "BUY", "test", slippage_pct=5.0) + assert check_slippage(r, 2.0) is False + + def test_execute_halts_on_slippage(self, sample_exchange_quotes): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 600.0, "dry_run", 0, strike=67500, option_type="call")], dry_run=True) + report = execute_plan(plan, DryRunExecutor(sample_exchange_quotes), max_slippage_pct=1.0) + assert not report.all_filled and "Slippage exceeded" in report.summary + + def test_execute_ok_slippage(self, sample_exchange_quotes): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 655.0, "dry_run", 0, strike=67500, option_type="call")], dry_run=True) + report = execute_plan(plan, DryRunExecutor(sample_exchange_quotes), max_slippage_pct=5.0) + assert report.all_filled + + +# --- 8. Execution Log (1 test — comprehensive) --- + +class TestExecutionLog: + def test_save_and_load(self, sample_strategy, sample_exchange_quotes, btc_option_data, tmp_path): + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + plan.dry_run = True + report = execute_plan(plan, DryRunExecutor(sample_exchange_quotes)) + log_path = str(tmp_path / "exec_log.json") + save_execution_log(report, log_path) + with open(log_path) as f: + log = json.load(f) + assert log["mode"] == "dry_run" and log["asset"] == "BTC" and log["all_filled"] + assert log["started_at"] and log["finished_at"] + assert isinstance(log["cancelled_orders"], list) + fill = log["fills"][0] + assert fill["status"] == "simulated" and fill["timestamp"] and fill["latency_ms"] >= 0 + + +# --- 9. Retry (5 tests) --- + +class TestRetryable: + def test_timeout(self): + assert _is_retryable(requests.Timeout()) is True + + def test_connection_error(self): + assert _is_retryable(requests.ConnectionError()) is True + + def test_value_error_not(self): + assert _is_retryable(ValueError("bad")) is False + + def test_http_429(self): + resp = type("R", (), {"status_code": 429})() + assert _is_retryable(requests.HTTPError(response=resp)) is True + + def test_http_400_not(self): + resp = type("R", (), {"status_code": 400})() + assert _is_retryable(requests.HTTPError(response=resp)) is False + + +# --- 10. Monitoring + Cancel (7 tests) --- + +class TestMonitoringAndCancel: + def test_immediate_fill(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + placed = executor.place_order( + OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, strike=67500, option_type="call")) + result = _monitor_order(executor, placed.order_id, timeout_seconds=5.0) + assert result.status == "simulated" + + def test_timeout_triggers_cancel(self): + class StuckExec(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, oid): return _status_result(oid, "open") + def cancel_order(self, oid): return True + assert _monitor_order(StuckExec(), "s-1", timeout_seconds=0.1, poll_interval=0.05).status == "timeout" + + def test_delayed_fill(self): + n = {"c": 0} + class DelayExec(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, oid): + n["c"] += 1 + return OrderResult(oid, "filled", 100.0, 1, "X", "BUY", "t") if n["c"] >= 3 else _status_result(oid, "open") + def cancel_order(self, oid): return True + result = _monitor_order(DelayExec(), "d-1", timeout_seconds=5.0, poll_interval=0.01) + assert result.status == "filled" and result.fill_price == 100.0 + + def test_rejected_terminal(self): + class RejExec(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, oid): return _status_result(oid, "rejected") + def cancel_order(self, oid): return True + assert _monitor_order(RejExec(), "r-1", timeout_seconds=5.0).status == "rejected" + + def test_cancel_filled_orders(self): + results = [OrderResult("id-1", "filled", 100.0, 1, "X", "BUY", "deribit"), + OrderResult("id-2", "filled", 200.0, 1, "Y", "SELL", "aevo")] + log = [] + class TrackExec(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, oid): return _status_result(oid, "filled") + def cancel_order(self, oid): log.append(oid); return True + assert _cancel_filled_orders(results, lambda ex: TrackExec()) == ["id-1", "id-2"] + + def test_cancel_skips_non_filled(self): + results = [OrderResult("id-1", "error", 0.0, 0, "X", "BUY", "deribit"), + OrderResult("id-2", "filled", 200.0, 1, "Y", "SELL", "aevo")] + executor = DryRunExecutor([]) + executor._orders["id-2"] = results[1] + assert _cancel_filled_orders(results, lambda ex: executor) == ["id-2"] + + def test_cancel_failure_skips(self): + results = [OrderResult("id-1", "filled", 100.0, 1, "X", "BUY", "deribit")] + class FailExec(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, oid): return _status_result(oid, "filled") + def cancel_order(self, oid): return False + assert _cancel_filled_orders(results, lambda ex: FailExec()) == [] + + +# --- 11. Execution Savings (3 tests) --- + +class TestExecutionSavings: + def test_savings_when_cheaper(self, btc_option_data): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="auto", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 600.0, "aevo", 0, strike=67500, option_type="call")]) + s = compute_execution_savings(plan, btc_option_data) + assert s["savings_usd"] > 0 and s["synth_theoretical_cost"] == pytest.approx(638.43) + + def test_no_savings_at_synth_price(self, btc_option_data): + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="auto", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 638.43, "deribit", 0, strike=67500, option_type="call")]) + assert compute_execution_savings(plan, btc_option_data)["savings_usd"] == pytest.approx(0.0) + + def test_multi_leg_savings(self, btc_option_data): + plan = ExecutionPlan(strategy_description="T", strategy_type="call_debit_spread", exchange="auto", asset="BTC", expiry="", + orders=[ + OrderRequest("BTC-67000-C", "BUY", 1, "limit", 950.0, "aevo", 0, strike=67000, option_type="call"), + OrderRequest("BTC-68000-C", "SELL", 1, "limit", 390.0, "deribit", 1, strike=68000, option_type="call"), + ]) + assert compute_execution_savings(plan, btc_option_data)["savings_usd"] > 0 + + +# --- 12. Aevo L2 Signing (5 tests) --- + +def _make_aevo(**kwargs): + defaults = { + "api_key": "test-key", "api_secret": "test-secret", + "signing_key": "0x" + "ab" * 32, + "wallet_address": "0x" + "cd" * 20, + "testnet": True, + } + defaults.update(kwargs) + return AevoExecutor(**defaults) + + +class TestAevoSigning: + def test_hmac_four_part_signature(self): + executor = _make_aevo() + sig = executor._sign_hmac("12345", "POST", "/orders", '{"side":"buy"}') + expected = hmac_mod.new(b"test-secret", b'12345POST/orders{"side":"buy"}', hashlib.sha256).hexdigest() + assert sig == expected + + def test_headers(self): + h = _make_aevo(api_key="my-key", api_secret="my-secret")._headers("POST", "/orders", '{}') + assert h["AEVO-KEY"] == "my-key" and "AEVO-TIMESTAMP" in h and "AEVO-SIGNATURE" in h + + def test_empty_body(self): + assert len(_make_aevo(api_key="k", api_secret="s")._sign_hmac("9", "GET", "/x", "")) == 64 + + def test_eip712_order_signing(self): + executor = _make_aevo() + salt, sig = executor._sign_order( + instrument_id=11235, is_buy=True, + limit_price=65500.0, quantity=1, timestamp=1700000000, + ) + assert isinstance(salt, int) and salt > 0 + assert sig.startswith("0x") and len(sig) == 132 # 0x + 65 bytes hex + + def test_authenticate_requires_all_four(self): + full = _make_aevo() + assert full.authenticate() is True + partial = _make_aevo(signing_key="", wallet_address="") + assert partial.authenticate() is False + + +# --- 13. Deribit Price Conversion (4 tests) --- + +class TestDeribitPriceConversion: + def test_align_tick(self): + assert DeribitExecutor._align_tick(0.00973, 0.0005) == 0.0095 + assert DeribitExecutor._align_tick(0.00975, 0.0005) == 0.01 + assert DeribitExecutor._align_tick(0.0100, 0.0005) == 0.01 + + def test_usd_to_btc(self): + executor = DeribitExecutor("id", "secret", testnet=True) + executor._index_cache["BTC"] = 67000.0 + assert executor._usd_to_btc(670.0, "BTC") == 0.01 + + def test_usd_to_btc_fallback(self): + executor = DeribitExecutor("id", "secret", testnet=True) + executor._index_cache["BTC"] = 0.0 + assert executor._usd_to_btc(670.0, "BTC") == 670.0 + + def test_index_cache(self): + executor = DeribitExecutor("id", "secret", testnet=True) + executor._index_cache["ETH"] = 3500.0 + assert executor._get_index_price("ETH") == 3500.0 + + +# --- 14. CLI Helpers (5 tests) --- + +class TestCLI: + def test_screen_none(self): + from main import _parse_screen_arg + assert _parse_screen_arg("none") == set() + + def test_screen_zero(self): + from main import _parse_screen_arg + assert _parse_screen_arg("0") == set() + + def test_screen_all(self): + from main import _parse_screen_arg + assert _parse_screen_arg("all") == {1, 2, 3, 4} + + def test_refuse_execution_blocks(self): + from main import _refuse_execution + assert _refuse_execution(True, "Countermove", False) is not None + + def test_refuse_execution_allows_force(self): + from main import _refuse_execution + assert _refuse_execution(True, "Countermove", True) is None diff --git a/tools/options-gps/tests/test_executor_e2e.py b/tools/options-gps/tests/test_executor_e2e.py new file mode 100644 index 0000000..04c7bfb --- /dev/null +++ b/tools/options-gps/tests/test_executor_e2e.py @@ -0,0 +1,269 @@ +"""End-to-end scripted test for autonomous execution (issue #26). +Runs the full pipeline with Synth mock + exchange mock data and verifies +execution flows through plan building to dry-run simulation.""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from pipeline import ( + generate_strategies, + rank_strategies, + select_three_cards, + forecast_confidence, + run_forecast_fusion, +) +from exchange import ( + fetch_all_exchanges, + strategy_divergence, + leg_divergences, +) +from executor import ( + build_execution_plan, + validate_plan, + execute_plan, + get_executor, + DryRunExecutor, +) + +MOCK_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "mock_data", "exchange_options") + +OPTION_DATA = { + "current_price": 67723, + "call_options": { + "65000": 2847.68, "66000": 1864.60, "67000": 987.04, + "67500": 638.43, "68000": 373.27, "68500": 197.11, + "69000": 93.43, "70000": 15.13, + }, + "put_options": { + "65000": 0.99, "66000": 17.91, "67000": 140.36, + "67500": 291.75, "68000": 526.59, "68500": 850.42, + "69000": 1246.74, "70000": 2168.44, + }, +} + +P24H = { + "0.05": 66000, "0.2": 67000, "0.35": 67400, + "0.5": 67800, "0.65": 68200, "0.8": 68800, "0.95": 70000, +} + +CURRENT_PRICE = 67723.0 + + +def test_full_execution_pipeline(): + """Load Synth mock + exchange mock -> rank -> build plan -> dry-run execute -> verify report.""" + # Step 1: Load exchange quotes from mock + quotes = fetch_all_exchanges("BTC", mock_dir=MOCK_DIR) + assert len(quotes) > 0, "Should load exchange quotes from mock" + + # Step 2: Generate and rank strategies + candidates = generate_strategies(OPTION_DATA, "bullish", "medium", asset="BTC", + expiry="2026-02-26 08:00:00Z") + assert len(candidates) > 0 + + divergence_by_strategy = {} + for c in candidates: + div = strategy_divergence(c, quotes, OPTION_DATA) + if div is not None: + divergence_by_strategy[id(c)] = div + + fusion = run_forecast_fusion(None, P24H, CURRENT_PRICE) + confidence = forecast_confidence(P24H, CURRENT_PRICE) + outcome_prices = [float(P24H[k]) for k in sorted(P24H.keys())] + + scored = rank_strategies( + candidates, fusion, "bullish", outcome_prices, "medium", CURRENT_PRICE, + confidence=confidence, divergence_by_strategy=divergence_by_strategy, + ) + best, safer, upside = select_three_cards(scored) + assert best is not None + + # Step 3: Build execution plan + plan = build_execution_plan(best, "BTC", "deribit", quotes, OPTION_DATA) + assert len(plan.orders) > 0 + assert plan.strategy_description == best.strategy.description + assert plan.asset == "BTC" + + # Step 4: Validate plan + valid, err = validate_plan(plan) + assert valid, f"Plan should be valid: {err}" + + # Step 5: All orders should have strike and option_type populated + for order in plan.orders: + assert order.strike > 0 + assert order.option_type in ("call", "put") + assert order.price > 0 + + # Step 6: Dry-run execute + plan.dry_run = True + executor = get_executor("deribit", quotes, dry_run=True) + assert isinstance(executor, DryRunExecutor) + + report = execute_plan(plan, executor) + assert report.all_filled is True + assert len(report.results) == len(plan.orders) + + # Step 7: Verify results + for result in report.results: + assert result.status == "simulated" + assert result.fill_price > 0 + assert result.fill_quantity > 0 + + # Step 8: Net cost should be positive for a long call (BUY) + if best.strategy.strategy_type == "long_call": + assert report.net_cost > 0 + + +def test_multi_leg_execution_pipeline(): + """Spread strategy -> build plan -> dry-run -> verify both legs fill.""" + quotes = fetch_all_exchanges("BTC", mock_dir=MOCK_DIR) + candidates = generate_strategies(OPTION_DATA, "bullish", "medium", asset="BTC", + expiry="2026-02-26 08:00:00Z") + + # Find a multi-leg strategy (call debit spread) + spreads = [c for c in candidates if c.strategy_type == "call_debit_spread"] + if not spreads: + return # no spread available, skip + spread = spreads[0] + + from pipeline import ScoredStrategy + scored = ScoredStrategy( + strategy=spread, probability_of_profit=0.5, expected_value=50.0, + tail_risk=40.0, loss_profile="defined risk", + invalidation_trigger="Close on break", reroute_rule="Roll out", + review_again_at="Review at 50%", score=0.7, rationale="Test", + ) + + plan = build_execution_plan(scored, "BTC", None, quotes, OPTION_DATA) + assert len(plan.orders) == 2 + assert plan.exchange == "auto" + + # One BUY, one SELL + actions = {o.action for o in plan.orders} + assert actions == {"BUY", "SELL"} + + plan.dry_run = True + executor = DryRunExecutor(quotes) + report = execute_plan(plan, executor) + assert report.all_filled is True + assert len(report.results) == 2 + + # Net cost should be positive (debit spread) + assert report.net_cost > 0 + + +def test_non_crypto_skips_execution(): + """XAU asset -> no exchange data -> execution not possible.""" + quotes = fetch_all_exchanges("XAU", mock_dir=MOCK_DIR) + assert quotes == [] + + +def test_auto_route_factory_e2e(): + """Auto-routing with factory callable should execute all legs.""" + quotes = fetch_all_exchanges("BTC", mock_dir=MOCK_DIR) + candidates = generate_strategies(OPTION_DATA, "bullish", "medium", asset="BTC", + expiry="2026-02-26 08:00:00Z") + spreads = [c for c in candidates if c.strategy_type == "call_debit_spread"] + if not spreads: + return + spread = spreads[0] + + from pipeline import ScoredStrategy + scored = ScoredStrategy( + strategy=spread, probability_of_profit=0.5, expected_value=50.0, + tail_risk=40.0, loss_profile="defined risk", + invalidation_trigger="Close on break", reroute_rule="Roll out", + review_again_at="Review at 50%", score=0.7, rationale="Test", + ) + + plan = build_execution_plan(scored, "BTC", None, quotes, OPTION_DATA) + plan.dry_run = True + + # Use factory callable (simulates live auto-routing path) + def _factory(exchange: str): + return DryRunExecutor(quotes) + + report = execute_plan(plan, _factory) + assert report.all_filled is True + assert len(report.results) == 2 + + +def test_execution_report_timing(): + """Execution report has started_at and finished_at populated.""" + quotes = fetch_all_exchanges("BTC", mock_dir=MOCK_DIR) + candidates = generate_strategies(OPTION_DATA, "bullish", "medium", asset="BTC", + expiry="2026-02-26 08:00:00Z") + assert len(candidates) > 0 + fusion = run_forecast_fusion(None, P24H, CURRENT_PRICE) + confidence = forecast_confidence(P24H, CURRENT_PRICE) + outcome_prices = [float(P24H[k]) for k in sorted(P24H.keys())] + + scored = rank_strategies( + candidates, fusion, "bullish", outcome_prices, "medium", CURRENT_PRICE, + confidence=confidence, + ) + best, _, _ = select_three_cards(scored) + plan = build_execution_plan(best, "BTC", "deribit", quotes, OPTION_DATA) + plan.dry_run = True + executor = DryRunExecutor(quotes) + report = execute_plan(plan, executor) + assert report.started_at != "" + assert report.finished_at != "" + assert report.all_filled is True + # Every fill should have a timestamp + for r in report.results: + assert r.timestamp != "" + assert r.latency_ms >= 0 + + +def test_guardrail_blocks_live_on_no_trade(): + """When no_trade_reason is active, _refuse_execution returns a message.""" + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + from main import _refuse_execution + result = _refuse_execution(True, "Countermove detected", False) + assert result is not None + assert "Guardrail" in result + + +def test_guardrail_allows_force(): + """With --force, _refuse_execution returns None (allowed).""" + from main import _refuse_execution + result = _refuse_execution(True, "Countermove detected", True) + assert result is None + + +def test_guardrail_allows_dry_run(): + """Dry-run ignores guardrail (not live execution).""" + from main import _refuse_execution + result = _refuse_execution(False, "Low confidence", False) + assert result is None + + +def test_guardrail_no_reason_allows_live(): + """When no_trade_reason is None, live execution proceeds.""" + from main import _refuse_execution + result = _refuse_execution(True, None, False) + assert result is None + + +if __name__ == "__main__": + test_full_execution_pipeline() + print("PASS: test_full_execution_pipeline") + test_multi_leg_execution_pipeline() + print("PASS: test_multi_leg_execution_pipeline") + test_non_crypto_skips_execution() + print("PASS: test_non_crypto_skips_execution") + test_auto_route_factory_e2e() + print("PASS: test_auto_route_factory_e2e") + test_execution_report_timing() + print("PASS: test_execution_report_timing") + test_guardrail_blocks_live_on_no_trade() + print("PASS: test_guardrail_blocks_live_on_no_trade") + test_guardrail_allows_force() + print("PASS: test_guardrail_allows_force") + test_guardrail_allows_dry_run() + print("PASS: test_guardrail_allows_dry_run") + test_guardrail_no_reason_allows_live() + print("PASS: test_guardrail_no_reason_allows_live") + print("\nAll executor E2E tests passed.")