From c0eb4ca18bd7588b896d9f9f205c6c3474dc44aa Mon Sep 17 00:00:00 2001 From: eureka928 Date: Thu, 12 Mar 2026 22:09:51 +0100 Subject: [PATCH 1/6] feat: autonomous execution engine for Options GPS (#26) Add --execute, --dry-run, and --exchange CLI flags with Screen 5 execution UI. Supports Deribit and Aevo via ABC executor pattern with dry-run simulation. 37 new tests (156 total passing), no changes to pipeline.py or exchange.py. --- tools/options-gps/PR_SUMMARY.md | 61 +++ tools/options-gps/executor.py | 531 +++++++++++++++++++ tools/options-gps/main.py | 105 ++++ tools/options-gps/tests/test_executor.py | 305 +++++++++++ tools/options-gps/tests/test_executor_e2e.py | 169 ++++++ 5 files changed, 1171 insertions(+) create mode 100644 tools/options-gps/PR_SUMMARY.md create mode 100644 tools/options-gps/executor.py create mode 100644 tools/options-gps/tests/test_executor.py create mode 100644 tools/options-gps/tests/test_executor_e2e.py 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/executor.py b/tools/options-gps/executor.py new file mode 100644 index 0000000..7d9f61e --- /dev/null +++ b/tools/options-gps/executor.py @@ -0,0 +1,531 @@ +"""Autonomous execution engine for Options GPS. +Consumes pipeline.py data classes and exchange.py pricing functions. +Supports Deribit, Aevo, and dry-run (simulated) execution.""" + +import hashlib +import hmac +import os +import time +import uuid +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from exchange import best_execution_price, leg_divergences + + +# --- 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" + fill_price: float + fill_quantity: int + instrument: str + action: str + exchange: str + error: str | None = None + + +@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 + + +@dataclass +class ExecutionReport: + plan: ExecutionPlan + results: list[OrderResult] = field(default_factory=list) + all_filled: bool = False + net_cost: float = 0.0 + summary: str = "" + + +# --- 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" + + +# --- 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) -> str: + ... + + @abstractmethod + def cancel_order(self, order_id: str) -> bool: + ... + + +class DryRunExecutor(BaseExecutor): + """Simulates order execution using exchange quote data. No network calls.""" + + def __init__(self, exchange_quotes: list): + self.exchange_quotes = exchange_quotes + + def authenticate(self) -> bool: + return True + + def place_order(self, order: OrderRequest) -> OrderResult: + if not order.strike or not order.option_type: + return 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", + ) + 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 + return 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", + ) + + def get_order_status(self, order_id: str) -> str: + return "simulated" + + def cancel_order(self, order_id: str) -> bool: + return True + + +class DeribitExecutor(BaseExecutor): + """Executes orders on Deribit via REST API.""" + + 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 + + def authenticate(self) -> bool: + import requests + try: + resp = requests.get( + f"{self.base_url}/public/auth", + params={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + self.token = data.get("result", {}).get("access_token") + return self.token is not None + except Exception: + return False + + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self.token}"} + + def place_order(self, order: OrderRequest) -> OrderResult: + import requests + endpoint = "buy" if order.action == "BUY" else "sell" + params = { + "instrument_name": order.instrument, + "amount": order.quantity, + "type": order.order_type, + } + if order.order_type == "limit": + params["price"] = order.price + try: + resp = requests.get( + f"{self.base_url}/private/{endpoint}", + params=params, + headers=self._headers(), + timeout=10, + ) + resp.raise_for_status() + data = resp.json().get("result", {}) + order_data = data.get("order", {}) + return OrderResult( + order_id=order_data.get("order_id", ""), + status=order_data.get("order_state", "error"), + fill_price=float(order_data.get("average_price", 0)), + fill_quantity=int(order_data.get("filled_amount", 0)), + instrument=order.instrument, + action=order.action, + exchange="deribit", + ) + except Exception as e: + return OrderResult( + order_id="", status="error", fill_price=0.0, + fill_quantity=0, instrument=order.instrument, + action=order.action, exchange="deribit", error=str(e), + ) + + def get_order_status(self, order_id: str) -> str: + import requests + try: + resp = requests.get( + f"{self.base_url}/private/get_order_state", + params={"order_id": order_id}, + headers=self._headers(), + timeout=10, + ) + resp.raise_for_status() + return resp.json().get("result", {}).get("order_state", "unknown") + except Exception: + return "unknown" + + def cancel_order(self, order_id: str) -> bool: + import requests + try: + resp = requests.get( + f"{self.base_url}/private/cancel", + params={"order_id": order_id}, + headers=self._headers(), + timeout=10, + ) + resp.raise_for_status() + return True + except Exception: + return False + + +class AevoExecutor(BaseExecutor): + """Executes orders on Aevo via REST API with HMAC-SHA256 signing.""" + + def __init__(self, api_key: str, api_secret: str, testnet: bool = False): + self.api_key = api_key + self.api_secret = api_secret + self.base_url = ( + "https://api-testnet.aevo.xyz" + if testnet + else "https://api.aevo.xyz" + ) + + def _sign(self, timestamp: str, body: str) -> str: + message = f"{timestamp}{body}" + return hmac.new( + self.api_secret.encode(), message.encode(), hashlib.sha256, + ).hexdigest() + + def _headers(self, body: str = "") -> dict: + ts = str(int(time.time())) + return { + "AEVO-KEY": self.api_key, + "AEVO-TIMESTAMP": ts, + "AEVO-SIGNATURE": self._sign(ts, body), + "Content-Type": "application/json", + } + + def authenticate(self) -> bool: + return bool(self.api_key and self.api_secret) + + def place_order(self, order: OrderRequest) -> OrderResult: + import json + import requests + payload = { + "instrument": order.instrument, + "side": order.action.lower(), + "quantity": order.quantity, + "order_type": order.order_type, + } + if order.order_type == "limit": + payload["price"] = order.price + body = json.dumps(payload) + try: + resp = requests.post( + f"{self.base_url}/orders", + data=body, + headers=self._headers(body), + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + return OrderResult( + order_id=data.get("order_id", ""), + status=data.get("status", "error"), + fill_price=float(data.get("avg_price", 0)), + fill_quantity=int(data.get("filled", 0)), + instrument=order.instrument, + action=order.action, + exchange="aevo", + ) + except Exception as e: + return OrderResult( + order_id="", status="error", fill_price=0.0, + fill_quantity=0, instrument=order.instrument, + action=order.action, exchange="aevo", error=str(e), + ) + + def get_order_status(self, order_id: str) -> str: + import requests + try: + resp = requests.get( + f"{self.base_url}/orders/{order_id}", + headers=self._headers(), + timeout=10, + ) + resp.raise_for_status() + return resp.json().get("status", "unknown") + except Exception: + return "unknown" + + def cancel_order(self, order_id: str) -> bool: + import requests + try: + resp = requests.delete( + f"{self.base_url}/orders/{order_id}", + headers=self._headers(), + timeout=10, + ) + resp.raise_for_status() + return True + except Exception: + return False + + +# --- Orchestration --- + +def build_execution_plan(scored, asset: str, exchange: str | None, + exchange_quotes: list, synth_options: dict) -> ExecutionPlan: + """Build an ExecutionPlan from a ScoredStrategy. + When exchange is None, auto-routes each leg to the best exchange via leg_divergences.""" + 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: + # Fallback: find best execution price across all quotes + 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 + + plan.orders.append(OrderRequest( + instrument=instrument, + action=leg.action, + quantity=leg.quantity, + order_type="limit", + price=price, + exchange=leg_exchange, + leg_index=i, + strike=leg.strike, + option_type=leg.option_type.lower(), + )) + + # Estimated cost: sum of buy prices - sum of sell prices + 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 + + return plan + + +def validate_plan(plan: ExecutionPlan) -> tuple[bool, str]: + """Validate an execution plan before submission. + 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}'" + return True, "" + + +def execute_plan(plan: ExecutionPlan, executor: BaseExecutor) -> ExecutionReport: + """Execute all orders in a plan sequentially. + On partial failure, warns about filled legs that need manual closing.""" + report = ExecutionReport(plan=plan) + + if not executor.authenticate(): + report.summary = "Authentication failed" + return report + + for order in plan.orders: + result = executor.place_order(order) + report.results.append(result) + if result.status in ("error", "rejected"): + filled_legs = [r for r in report.results if r.status in ("filled", "simulated")] + if filled_legs: + instruments = ", ".join(r.instrument for r in filled_legs) + report.summary = ( + f"Partial fill — order {order.leg_index} failed: " + f"{result.error or result.status}. " + f"WARNING: manually close 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) + return report + + report.all_filled = all( + r.status in ("filled", "simulated") for r in report.results + ) + report.net_cost = _compute_net_cost(report.results) + + 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" + + 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 get_executor(exchange: str | None, exchange_quotes: list, + dry_run: bool = False) -> BaseExecutor: + """Factory: return the appropriate executor. + dry_run=True always returns DryRunExecutor. + Otherwise reads credentials from environment variables.""" + if dry_run: + return DryRunExecutor(exchange_quotes) + + 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", "") + if not api_key or not api_secret: + raise ValueError( + "Aevo credentials required: set AEVO_API_KEY and " + "AEVO_API_SECRET environment variables" + ) + testnet = os.environ.get("AEVO_TESTNET", "").strip() == "1" + return AevoExecutor(api_key, api_secret, 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..19564f7 100644 --- a/tools/options-gps/main.py +++ b/tools/options-gps/main.py @@ -41,6 +41,13 @@ compute_edge, ) +from executor import ( + build_execution_plan, + validate_plan, + execute_plan, + get_executor, +) + SUPPORTED_ASSETS = ["BTC", "ETH", "SOL", "XAU", "SPY", "NVDA", "TSLA", "AAPL", "GOOGL"] @@ -671,6 +678,66 @@ def screen_if_wrong(best: ScoredStrategy | None, no_trade_reason: str | None, print(_footer()) +def screen_execution(best: ScoredStrategy, asset: str, exchange: str | None, + exchange_quotes: list, synth_options: dict, dry_run: bool = False, + no_prompt: bool = False): + """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(best, asset, exchange, exchange_quotes, synth_options) + plan.dry_run = dry_run + + valid, err = validate_plan(plan) + 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}") + 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 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) + + print(f"{BAR}") + print(_section("RESULTS")) + for result in report.results: + status_icon = "\u2713" if result.status in ("filled", "simulated") else "\u2717" + print(f"{BAR} {status_icon} {result.action} {result.instrument}: " + f"{result.status} @ ${result.fill_price:,.2f} x{result.fill_quantity}" + + (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}")) + print(f"{BAR} {report.summary}") + print(_footer()) + 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: @@ -728,6 +795,12 @@ def main(): help="Screens to show: comma-separated 1,2,3,4 or 'all' (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", action="store_true", + help="Execute the best strategy on a live exchange") + parser.add_argument("--dry-run", action="store_true", dest="dry_run", + help="Simulate execution (no real orders)") + parser.add_argument("--exchange", default=None, choices=["deribit", "aevo"], + help="Force exchange (default: auto-route per leg)") args = parser.parse_args() screens = _parse_screen_arg(args.screen) with warnings.catch_warnings(): @@ -806,6 +879,21 @@ 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 + if (args.execute or args.dry_run) and best is not None and exchange_quotes: + if shown_any: + _pause("Screen 5: Execution", args.no_prompt) + execution_report = screen_execution( + best, symbol, args.exchange, exchange_quotes, options, + dry_run=args.dry_run or not args.execute, + no_prompt=args.no_prompt, + ) + shown_any = True + elif (args.execute or args.dry_run) and best is None: + print(f"\n Cannot execute: no strategy recommendation available.") + elif (args.execute or args.dry_run) and not exchange_quotes: + print(f"\n Cannot execute: exchange data not available (crypto assets only).") if shown_any: _pause("Decision Log", args.no_prompt) decision_log = { @@ -829,6 +917,23 @@ 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), + "fills": [ + { + "instrument": r.instrument, + "action": r.action, + "status": r.status, + "fill_price": round(r.fill_price, 2), + "fill_quantity": r.fill_quantity, + } + 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/tests/test_executor.py b/tools/options-gps/tests/test_executor.py new file mode 100644 index 0000000..bf11f85 --- /dev/null +++ b/tools/options-gps/tests/test_executor.py @@ -0,0 +1,305 @@ +"""Tests for executor.py: autonomous execution — instrument names, plan building, +validation, dry-run simulation, execution flow, and executor factory.""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from executor import ( + OrderRequest, + ExecutionPlan, + DryRunExecutor, + deribit_instrument_name, + aevo_instrument_name, + build_execution_plan, + validate_plan, + execute_plan, + get_executor, +) +from exchange import _parse_instrument_key +from pipeline import ScoredStrategy + + +def _make_scored(strategy): + """Wrap a StrategyCandidate into a ScoredStrategy for testing.""" + 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", + ) + + +class TestInstrumentNames: + def test_deribit_instrument_name(self): + name = deribit_instrument_name("BTC", "2026-02-26T08:00:00Z", 67500, "Call") + assert name == "BTC-26FEB26-67500-C" + + def test_deribit_instrument_name_put(self): + name = deribit_instrument_name("ETH", "2026-03-15T08:00:00Z", 4000, "Put") + assert name == "ETH-15MAR26-4000-P" + + def test_aevo_instrument_name(self): + name = aevo_instrument_name("BTC", 67500, "Call") + assert name == "BTC-67500-C" + + def test_aevo_instrument_name_put(self): + name = aevo_instrument_name("SOL", 150, "Put") + assert name == "SOL-150-P" + + def test_deribit_roundtrip(self): + """Build a Deribit name then parse it back — should recover strike and type.""" + name = deribit_instrument_name("BTC", "2026-02-26T08:00:00Z", 67500, "Call") + parsed = _parse_instrument_key(name) + assert parsed is not None + strike, opt_type = parsed + assert strike == 67500 + assert opt_type == "call" + + def test_aevo_roundtrip(self): + """Build an Aevo name then parse it back — should recover strike and type.""" + name = aevo_instrument_name("BTC", 68000, "Put") + parsed = _parse_instrument_key(name) + assert parsed is not None + strike, opt_type = parsed + assert strike == 68000 + assert opt_type == "put" + + def test_deribit_empty_expiry(self): + """Empty expiry falls back to UNKNOWN date part.""" + name = deribit_instrument_name("BTC", "", 67500, "Call") + assert "UNKNOWN" in name + + +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 + assert plan.orders[0].action == "BUY" + assert plan.orders[0].exchange == "deribit" + assert plan.orders[0].strike == 67500 + assert plan.orders[0].option_type == "call" + assert "67500" in plan.orders[0].instrument + assert 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 "BUY" in actions + assert "SELL" in actions + + def test_exchange_override(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + """--exchange deribit → all orders use Deribit names.""" + scored = _make_scored(multi_leg_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + for order in plan.orders: + assert order.exchange == "deribit" + assert "BTC-" in order.instrument + + def test_aevo_names(self, sample_strategy, sample_exchange_quotes, btc_option_data): + """--exchange aevo → Aevo instrument names (no date).""" + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "aevo", sample_exchange_quotes, btc_option_data) + assert len(plan.orders) == 1 + assert plan.orders[0].instrument == "BTC-67500-C" + + def test_auto_route(self, sample_strategy, sample_exchange_quotes, btc_option_data): + """exchange=None → auto-routes via leg_divergences.""" + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", None, sample_exchange_quotes, btc_option_data) + assert plan.exchange == "auto" + assert len(plan.orders) == 1 + # Should pick a valid exchange + assert plan.orders[0].exchange in ("deribit", "aevo") + + def test_estimated_cost_multi_leg(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + """estimated_cost = buy prices - sell prices.""" + scored = _make_scored(multi_leg_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + 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") + assert plan.estimated_cost == pytest.approx(buy_total - sell_total) + + +class TestValidatePlan: + def test_valid(self): + plan = ExecutionPlan( + strategy_description="Test", 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")], + ) + valid, err = validate_plan(plan) + assert valid is True + assert err == "" + + def test_empty_orders(self): + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="deribit", asset="BTC", expiry="", + ) + valid, err = validate_plan(plan) + assert valid is False + assert "No orders" in err + + def test_zero_price(self): + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-26FEB26-67500-C", "BUY", 1, "limit", 0.0, "deribit", 0, + strike=67500, option_type="call")], + ) + valid, err = validate_plan(plan) + assert valid is False + assert "price" in err.lower() + + def test_zero_quantity(self): + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-26FEB26-67500-C", "BUY", 0, "limit", 660.0, "deribit", 0, + strike=67500, option_type="call")], + ) + valid, err = validate_plan(plan) + assert valid is False + assert "quantity" in err.lower() + + def test_empty_instrument(self): + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("", "BUY", 1, "limit", 660.0, "deribit", 0, + strike=67500, option_type="call")], + ) + valid, err = validate_plan(plan) + assert valid is False + assert "instrument" in err.lower() + + +class TestDryRunExecutor: + def test_authenticate(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + assert executor.authenticate() is True + + def test_place_buy(self, sample_exchange_quotes): + """BUY fills at best ask from quotes.""" + executor = DryRunExecutor(sample_exchange_quotes) + order = OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, + strike=67500, option_type="call") + result = executor.place_order(order) + assert result.status == "simulated" + assert result.fill_quantity == 1 + assert result.fill_price == 655.0 # best ask from aevo + + def test_place_sell(self, sample_exchange_quotes): + """SELL fills at best bid from quotes.""" + executor = DryRunExecutor(sample_exchange_quotes) + order = OrderRequest("BTC-67500-C", "SELL", 1, "limit", 620.0, "dry_run", 0, + strike=67500, option_type="call") + result = executor.place_order(order) + assert result.status == "simulated" + assert result.fill_quantity == 1 + assert result.fill_price == 620.0 # best bid from aevo + + def test_missing_strike(self, sample_exchange_quotes): + """Order without strike/option_type returns error.""" + executor = DryRunExecutor(sample_exchange_quotes) + order = OrderRequest("INVALID", "BUY", 1, "limit", 100.0, "dry_run", 0) + result = executor.place_order(order) + assert result.status == "error" + + def test_no_matching_quote_uses_order_price(self, sample_exchange_quotes): + """When no exchange quote matches, falls back to order limit price.""" + executor = DryRunExecutor(sample_exchange_quotes) + order = OrderRequest("BTC-99999-C", "BUY", 1, "limit", 100.0, "dry_run", 0, + strike=99999, option_type="call") + result = executor.place_order(order) + assert result.status == "simulated" + assert result.fill_price == 100.0 + + def test_get_order_status(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + assert executor.get_order_status("any-id") == "simulated" + + def test_cancel_order(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + assert executor.cancel_order("any-id") is True + + +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 + executor = DryRunExecutor(sample_exchange_quotes) + report = execute_plan(plan, executor) + assert report.all_filled is True + assert len(report.results) == 1 + assert report.results[0].status == "simulated" + + 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 + executor = DryRunExecutor(sample_exchange_quotes) + report = execute_plan(plan, executor) + assert report.all_filled is True + assert len(report.results) == 2 + + def test_net_cost(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + """net_cost = sum(buy fills) - sum(sell fills).""" + scored = _make_scored(multi_leg_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + plan.dry_run = True + executor = DryRunExecutor(sample_exchange_quotes) + report = execute_plan(plan, executor) + buy_total = sum(r.fill_price * r.fill_quantity for r in report.results if r.action == "BUY") + sell_total = sum(r.fill_price * r.fill_quantity for r in report.results if r.action == "SELL") + assert report.net_cost == pytest.approx(buy_total - sell_total) + + 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 + executor = DryRunExecutor(sample_exchange_quotes) + report = execute_plan(plan, executor) + assert "simulated" in report.summary + assert "Net cost" in report.summary + + +class TestGetExecutor: + def test_dry_run(self, sample_exchange_quotes): + executor = get_executor("deribit", sample_exchange_quotes, dry_run=True) + assert isinstance(executor, DryRunExecutor) + + def test_dry_run_ignores_exchange(self, sample_exchange_quotes): + """dry_run=True always returns DryRunExecutor regardless of exchange.""" + executor = get_executor("aevo", sample_exchange_quotes, dry_run=True) + assert isinstance(executor, 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) + 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) 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..c229252 --- /dev/null +++ b/tools/options-gps/tests/test_executor_e2e.py @@ -0,0 +1,169 @@ +"""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 == [] + + +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") + print("\nAll executor E2E tests passed.") From 308879e1a615a4818b7151f71896d818b1d674d4 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Fri, 13 Mar 2026 21:09:49 +0100 Subject: [PATCH 2/6] feat: order monitoring, auto-cancel, execution savings, Aevo HMAC fix - Order monitoring: poll open orders until filled/timeout, auto-cancel on timeout - Auto-cancel: cancel already-filled legs when a later leg fails (partial fill safety) - Execution savings: compare auto-routed cost vs Synth theoretical price - Aevo HMAC-SHA256: fix to 4-part signing (timestamp+method+path+body) - Timestamps & latency: ISO 8601 timestamps and round-trip latency on every fill - --timeout CLI flag: configurable order monitoring timeout (default 30s) - Screen 5 enhancements: show latency, timestamps, cancelled orders, savings - Decision log: include slippage, timestamps, latency, cancelled orders per fill - 210 tests passing (29 new): monitoring, auto-cancel, savings, HMAC, guardrails --- tools/options-gps/README.md | 74 ++- tools/options-gps/executor.py | 460 ++++++++++++--- tools/options-gps/main.py | 120 +++- tools/options-gps/tests/test_executor.py | 580 +++++++++++++++++++ tools/options-gps/tests/test_executor_e2e.py | 108 ++++ 5 files changed, 1233 insertions(+), 109 deletions(-) diff --git a/tools/options-gps/README.md b/tools/options-gps/README.md index a6bd8f8..9391c8b 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. 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 index 7d9f61e..78da112 100644 --- a/tools/options-gps/executor.py +++ b/tools/options-gps/executor.py @@ -1,9 +1,13 @@ """Autonomous execution engine for Options GPS. Consumes pipeline.py data classes and exchange.py pricing functions. -Supports Deribit, Aevo, and dry-run (simulated) execution.""" +Supports Deribit (JSON-RPC 2.0), Aevo (REST + HMAC-SHA256), 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 time import uuid @@ -11,9 +15,18 @@ from dataclasses import dataclass, field from datetime import datetime, timezone +import requests + 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 @@ -32,13 +45,16 @@ class OrderRequest: @dataclass class OrderResult: order_id: str - status: str # "filled" | "open" | "rejected" | "error" | "simulated" + 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 @@ -52,6 +68,7 @@ class ExecutionPlan: estimated_cost: float = 0.0 estimated_max_loss: float = 0.0 dry_run: bool = False + timeout_seconds: float = 30.0 # order monitoring timeout @dataclass @@ -61,6 +78,10 @@ class ExecutionReport: 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 --- @@ -94,6 +115,29 @@ def _format_deribit_date(expiry: str) -> str: 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): @@ -124,12 +168,15 @@ 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: return 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, ) quote = best_execution_price( self.exchange_quotes, order.strike, order.option_type, order.action, @@ -138,6 +185,8 @@ def place_order(self, order: OrderRequest) -> OrderResult: 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 return OrderResult( order_id=f"dry-{uuid.uuid4().hex[:8]}", status="simulated", @@ -146,6 +195,9 @@ def place_order(self, order: OrderRequest) -> OrderResult: instrument=order.instrument, action=order.action, exchange="dry_run", + slippage_pct=slippage, + timestamp=ts, + latency_ms=round(latency, 2), ) def get_order_status(self, order_id: str) -> str: @@ -156,7 +208,9 @@ def cancel_order(self, order_id: str) -> bool: class DeribitExecutor(BaseExecutor): - """Executes orders on Deribit via REST API.""" + """Executes orders on Deribit via JSON-RPC 2.0 over POST. + Uses `contracts` parameter for unambiguous option sizing. + Retries on transient errors (429, 502, 503, timeout).""" def __init__(self, client_id: str, client_secret: str, testnet: bool = False): self.client_id = client_id @@ -167,96 +221,105 @@ def __init__(self, client_id: str, client_secret: str, testnet: bool = False): else "https://www.deribit.com/api/v2" ) self.token: str | None = None + self._rpc_id = 0 - def authenticate(self) -> bool: - import requests - try: - resp = requests.get( - f"{self.base_url}/public/auth", - params={ - "grant_type": "client_credentials", - "client_id": self.client_id, - "client_secret": self.client_secret, - }, - timeout=10, - ) + 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() - self.token = data.get("result", {}).get("access_token") + if "error" in data: + raise RuntimeError(data["error"].get("message", str(data["error"]))) + return data.get("result", {}) + + return _retry(_call) + + 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 _headers(self) -> dict: - return {"Authorization": f"Bearer {self.token}"} - def place_order(self, order: OrderRequest) -> OrderResult: - import requests - endpoint = "buy" if order.action == "BUY" else "sell" + t0 = time.monotonic() + ts = _now_iso() + method = "private/buy" if order.action == "BUY" else "private/sell" params = { "instrument_name": order.instrument, - "amount": order.quantity, + "contracts": order.quantity, "type": order.order_type, } if order.order_type == "limit": params["price"] = order.price try: - resp = requests.get( - f"{self.base_url}/private/{endpoint}", - params=params, - headers=self._headers(), - timeout=10, - ) - resp.raise_for_status() - data = resp.json().get("result", {}) - order_data = data.get("order", {}) + 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=float(order_data.get("average_price", 0)), + 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) -> str: - import requests try: - resp = requests.get( - f"{self.base_url}/private/get_order_state", - params={"order_id": order_id}, - headers=self._headers(), - timeout=10, - ) - resp.raise_for_status() - return resp.json().get("result", {}).get("order_state", "unknown") + result = self._rpc("private/get_order_state", {"order_id": order_id}) + return result.get("order_state", "unknown") except Exception: return "unknown" def cancel_order(self, order_id: str) -> bool: - import requests try: - resp = requests.get( - f"{self.base_url}/private/cancel", - params={"order_id": order_id}, - headers=self._headers(), - timeout=10, - ) - resp.raise_for_status() + 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 HMAC-SHA256 signing.""" + """Executes orders on Aevo via REST API with HMAC-SHA256 signing. + Signs timestamp + HTTP method + path + body per Aevo spec. + Retries on transient errors (429, 502, 503, timeout).""" def __init__(self, api_key: str, api_secret: str, testnet: bool = False): self.api_key = api_key @@ -267,18 +330,18 @@ def __init__(self, api_key: str, api_secret: str, testnet: bool = False): else "https://api.aevo.xyz" ) - def _sign(self, timestamp: str, body: str) -> str: - message = f"{timestamp}{body}" + def _sign(self, timestamp: str, method: str, path: str, body: str) -> str: + message = f"{timestamp}{method}{path}{body}" return hmac.new( self.api_secret.encode(), message.encode(), hashlib.sha256, ).hexdigest() - def _headers(self, body: str = "") -> dict: + 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(ts, body), + "AEVO-SIGNATURE": self._sign(ts, method, path, body), "Content-Type": "application/json", } @@ -286,8 +349,8 @@ def authenticate(self) -> bool: return bool(self.api_key and self.api_secret) def place_order(self, order: OrderRequest) -> OrderResult: - import json - import requests + t0 = time.monotonic() + ts = _now_iso() payload = { "instrument": order.instrument, "side": order.action.lower(), @@ -298,36 +361,46 @@ def place_order(self, order: OrderRequest) -> OrderResult: payload["price"] = order.price body = json.dumps(payload) try: - resp = requests.post( - f"{self.base_url}/orders", - data=body, - headers=self._headers(body), - timeout=10, - ) - resp.raise_for_status() - data = resp.json() + 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=float(data.get("avg_price", 0)), + 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) -> str: - import requests try: resp = requests.get( f"{self.base_url}/orders/{order_id}", - headers=self._headers(), + headers=self._headers("GET", f"/orders/{order_id}"), timeout=10, ) resp.raise_for_status() @@ -336,11 +409,10 @@ def get_order_status(self, order_id: str) -> str: return "unknown" def cancel_order(self, order_id: str) -> bool: - import requests try: resp = requests.delete( f"{self.base_url}/orders/{order_id}", - headers=self._headers(), + headers=self._headers("DELETE", f"/orders/{order_id}"), timeout=10, ) resp.raise_for_status() @@ -349,12 +421,66 @@ def cancel_order(self, order_id: str) -> bool: 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) -> str: + """Poll order status until terminal state or timeout. + Returns final status. On timeout, attempts to cancel the order.""" + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + status = executor.get_order_status(order_id) + if status in ("filled", "rejected", "cancelled", "error", "simulated"): + return status + 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 "timeout" + + +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) -> ExecutionPlan: + 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.""" + 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, @@ -377,7 +503,6 @@ def build_execution_plan(scored, asset: str, exchange: str | None, elif i in leg_routes: leg_exchange = leg_routes[i]["best_exchange"] else: - # Fallback: find best execution price across all quotes quote = best_execution_price( exchange_quotes, leg.strike, leg.option_type.lower(), leg.action, ) @@ -400,10 +525,11 @@ def build_execution_plan(scored, asset: str, exchange: str | None, else: price = leg.premium + qty = leg.quantity * max(1, size_multiplier) plan.orders.append(OrderRequest( instrument=instrument, action=leg.action, - quantity=leg.quantity, + quantity=qty, order_type="limit", price=price, exchange=leg_exchange, @@ -412,17 +538,17 @@ def build_execution_plan(scored, asset: str, exchange: str | None, option_type=leg.option_type.lower(), )) - # Estimated cost: sum of buy prices - sum of sell prices 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 + plan.estimated_max_loss = strategy.max_loss * max(1, size_multiplier) return plan -def validate_plan(plan: ExecutionPlan) -> tuple[bool, str]: +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" @@ -435,29 +561,105 @@ def validate_plan(plan: ExecutionPlan) -> tuple[bool, str]: 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: BaseExecutor) -> ExecutionReport: +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. - On partial failure, warns about filled legs that need manual closing.""" - report = ExecutionReport(plan=plan) + 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 - if not executor.authenticate(): - report.summary = "Authentication failed" - return report + use_timeout = timeout_seconds is not None and timeout_seconds > 0 for order in plan.orders: - result = executor.place_order(order) + 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): + final_status = _monitor_order( + ex, result.order_id, + timeout_seconds=timeout_seconds, + poll_interval=1.0, + ) + result.status = final_status + report.results.append(result) - if result.status in ("error", "rejected"): + + # 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}. " - f"WARNING: manually close filled legs: {instruments}" + f"{result.error or result.status}.{cancel_note} " + f"Filled legs: {instruments}" ) else: report.summary = ( @@ -465,12 +667,16 @@ def execute_plan(plan: ExecutionPlan, executor: BaseExecutor) -> ExecutionReport ) 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" @@ -481,6 +687,7 @@ def execute_plan(plan: ExecutionPlan, executor: BaseExecutor) -> ExecutionReport else: report.summary = f"Execution completed with {len(report.results)} orders" + report.finished_at = _now_iso() return report @@ -496,14 +703,93 @@ def _compute_net_cost(results: list[OrderResult]) -> float: 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) -> BaseExecutor: + 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", "") diff --git a/tools/options-gps/main.py b/tools/options-gps/main.py index 19564f7..616b3b6 100644 --- a/tools/options-gps/main.py +++ b/tools/options-gps/main.py @@ -46,6 +46,8 @@ validate_plan, execute_plan, get_executor, + save_execution_log, + compute_execution_savings, ) SUPPORTED_ASSETS = ["BTC", "ETH", "SOL", "XAU", "SPY", "NVDA", "TSLA", "AAPL", "GOOGL"] @@ -678,19 +680,24 @@ def screen_if_wrong(best: ScoredStrategy | None, no_trade_reason: str | None, print(_footer()) -def screen_execution(best: ScoredStrategy, asset: str, exchange: str | None, +def screen_execution(card: ScoredStrategy, asset: str, exchange: str | None, exchange_quotes: list, synth_options: dict, dry_run: bool = False, - no_prompt: 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(best, asset, exchange, exchange_quotes, synth_options) + plan = build_execution_plan(card, asset, exchange, exchange_quotes, synth_options, + size_multiplier=size) plan.dry_run = dry_run - valid, err = validate_plan(plan) + valid, err = validate_plan(plan, max_loss_budget=max_loss) if not valid: print(f"{BAR} Pre-flight FAILED: {err}") print(_footer()) @@ -699,6 +706,8 @@ def screen_execution(best: ScoredStrategy, asset: str, exchange: str | 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: @@ -708,6 +717,10 @@ def screen_execution(best: ScoredStrategy, asset: str, exchange: str | None, 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}") @@ -721,20 +734,42 @@ def screen_execution(best: ScoredStrategy, asset: str, exchange: str | None, print(_footer()) return None - report = execute_plan(plan, executor) + 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 @@ -795,12 +830,26 @@ def main(): help="Screens to show: comma-separated 1,2,3,4 or 'all' (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", action="store_true", - help="Execute the best strategy on a live exchange") - parser.add_argument("--dry-run", action="store_true", dest="dry_run", - help="Simulate execution (no real orders)") + 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(): @@ -881,19 +930,42 @@ def main(): shown_any = True # Screen 5: Execution (triggered by --execute or --dry-run, not by --screen) execution_report = None - if (args.execute or args.dry_run) and best is not None and exchange_quotes: - if shown_any: - _pause("Screen 5: Execution", args.no_prompt) - execution_report = screen_execution( - best, symbol, args.exchange, exchange_quotes, options, - dry_run=args.dry_run or not args.execute, - no_prompt=args.no_prompt, - ) + 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 + if is_live and no_trade_reason and not args.force: + print(f"\n Guardrail: {no_trade_reason}") + print(f" Use --force to override and execute anyway.") + 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 (args.execute or args.dry_run) and best is None: - print(f"\n Cannot execute: no strategy recommendation available.") - elif (args.execute or args.dry_run) and not exchange_quotes: - print(f"\n Cannot execute: exchange data not available (crypto assets only).") + 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 = { @@ -923,6 +995,9 @@ def main(): "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, @@ -930,6 +1005,9 @@ def main(): "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 ], diff --git a/tools/options-gps/tests/test_executor.py b/tools/options-gps/tests/test_executor.py index bf11f85..d659573 100644 --- a/tools/options-gps/tests/test_executor.py +++ b/tools/options-gps/tests/test_executor.py @@ -6,17 +6,31 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import requests import pytest +from unittest.mock import Mock, patch from executor import ( OrderRequest, + OrderResult, ExecutionPlan, + ExecutionReport, + BaseExecutor, DryRunExecutor, + AevoExecutor, 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, + _now_iso, ) from exchange import _parse_instrument_key from pipeline import ScoredStrategy @@ -303,3 +317,569 @@ def test_missing_aevo_creds(self, sample_exchange_quotes, monkeypatch): def test_unknown_exchange(self, sample_exchange_quotes): with pytest.raises(ValueError, match="Unknown exchange"): get_executor("binance", sample_exchange_quotes, dry_run=False) + + def test_auto_route_returns_factory(self, sample_exchange_quotes): + """exchange=None with dry_run=False returns a callable factory.""" + result = get_executor(None, sample_exchange_quotes, dry_run=False) + assert callable(result) + assert not isinstance(result, DryRunExecutor) + + def test_auto_route_dry_run_returns_executor(self, sample_exchange_quotes): + """exchange=None with dry_run=True still returns DryRunExecutor.""" + result = get_executor(None, sample_exchange_quotes, dry_run=True) + assert isinstance(result, DryRunExecutor) + + +class TestExecuteWithFactory: + def test_factory_routes_per_leg(self, sample_exchange_quotes): + """execute_plan with a callable factory creates executors per exchange.""" + plan = ExecutionPlan( + strategy_description="Test spread", 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, + ) + + def _factory(exchange: str) -> DryRunExecutor: + return DryRunExecutor(sample_exchange_quotes) + + report = execute_plan(plan, _factory) + assert report.all_filled is True + assert len(report.results) == 2 + + def test_factory_auth_failure(self, sample_exchange_quotes): + """Factory executor that fails auth stops execution.""" + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="auto", asset="BTC", expiry="", + orders=[ + OrderRequest("BTC-67500-C", "BUY", 1, "limit", 655.0, "bad_exchange", 0, + strike=67500, option_type="call"), + ], + dry_run=False, + ) + + class FailAuthExecutor(DryRunExecutor): + def authenticate(self): + return False + + def _factory(exchange: str): + return FailAuthExecutor(sample_exchange_quotes) + + report = execute_plan(plan, _factory) + assert report.all_filled is False + assert "Authentication failed" in report.summary + + +class TestSlippage: + def test_compute_slippage_buy_worse(self): + """BUY at higher price = positive slippage.""" + slip = _compute_slippage(100.0, 102.0, "BUY") + assert slip == pytest.approx(2.0) + + def test_compute_slippage_buy_better(self): + """BUY at lower price = negative slippage (favorable).""" + slip = _compute_slippage(100.0, 98.0, "BUY") + assert slip == pytest.approx(-2.0) + + def test_compute_slippage_sell_worse(self): + """SELL at lower price = positive slippage.""" + slip = _compute_slippage(100.0, 98.0, "SELL") + assert slip == pytest.approx(2.0) + + def test_compute_slippage_zero_limit(self): + """Zero limit price returns 0 slippage.""" + assert _compute_slippage(0.0, 100.0, "BUY") == 0.0 + + def test_check_slippage_within(self): + from executor import OrderResult + 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_slippage_exceeded(self): + from executor import OrderResult + 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_plan_halts_on_slippage(self, sample_exchange_quotes): + """execute_plan with max_slippage_pct halts when exceeded.""" + plan = ExecutionPlan( + strategy_description="Test", 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, + ) + executor = DryRunExecutor(sample_exchange_quotes) + # Fill will be at 655.0 (aevo ask), limit is 600 -> slippage ~9.2% + report = execute_plan(plan, executor, max_slippage_pct=1.0) + assert report.all_filled is False + assert "Slippage exceeded" in report.summary + + def test_execute_plan_ok_slippage(self, sample_exchange_quotes): + """execute_plan passes when slippage is within limit.""" + plan = ExecutionPlan( + strategy_description="Test", 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, + ) + executor = DryRunExecutor(sample_exchange_quotes) + # Fill at 655.0, limit 655.0 -> 0% slippage + report = execute_plan(plan, executor, max_slippage_pct=5.0) + assert report.all_filled is True + + +class TestMaxLossBudget: + def test_validate_plan_within_budget(self): + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "deribit", 0, + strike=67500, option_type="call")], + estimated_max_loss=500.0, + ) + valid, err = validate_plan(plan, max_loss_budget=1000.0) + assert valid is True + + def test_validate_plan_exceeds_budget(self): + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "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 valid is False + assert "budget" in err.lower() + + def test_validate_plan_no_budget(self): + """No budget = no check.""" + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="deribit", asset="BTC", expiry="", + orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "deribit", 0, + strike=67500, option_type="call")], + estimated_max_loss=99999.0, + ) + valid, err = validate_plan(plan) + assert valid is True + + +class TestSizeMultiplier: + def test_size_doubles_quantity(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_default_one(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 plan.orders[0].quantity == 1 + + def test_size_scales_max_loss(self, sample_strategy, sample_exchange_quotes, btc_option_data): + scored = _make_scored(sample_strategy) + plan1 = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data, + size_multiplier=1) + plan2 = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data, + size_multiplier=5) + assert plan2.estimated_max_loss == pytest.approx(plan1.estimated_max_loss * 5) + + +class TestExecutionLog: + def test_save_and_load(self, sample_strategy, sample_exchange_quotes, btc_option_data, tmp_path): + import json + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + plan.dry_run = True + executor = DryRunExecutor(sample_exchange_quotes) + report = execute_plan(plan, executor) + + 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" + assert log["asset"] == "BTC" + assert log["all_filled"] is True + assert len(log["fills"]) == 1 + assert log["fills"][0]["status"] == "simulated" + assert "timestamp" in log + assert "slippage_total_pct" in log + + +class TestRetryable: + def test_timeout_is_retryable(self): + assert _is_retryable(requests.Timeout()) is True + + def test_connection_error_is_retryable(self): + assert _is_retryable(requests.ConnectionError()) is True + + def test_value_error_not_retryable(self): + assert _is_retryable(ValueError("bad")) is False + + def test_http_429_retryable(self): + from unittest.mock import Mock + resp = Mock() + resp.status_code = 429 + err = requests.HTTPError(response=resp) + assert _is_retryable(err) is True + + def test_http_400_not_retryable(self): + from unittest.mock import Mock + resp = Mock() + resp.status_code = 400 + err = requests.HTTPError(response=resp) + assert _is_retryable(err) is False + + +class TestTimestampsAndLatency: + """Verify that OrderResult and ExecutionReport include timing data.""" + + def test_dry_run_result_has_timestamp(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") + result = executor.place_order(order) + assert result.timestamp != "" + assert "T" in result.timestamp # ISO 8601 format + + def test_dry_run_result_has_latency(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") + result = executor.place_order(order) + assert result.latency_ms >= 0 + + def test_error_result_has_timestamp(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + order = OrderRequest("INVALID", "BUY", 1, "limit", 100.0, "dry_run", 0) + result = executor.place_order(order) + assert result.timestamp != "" + + def test_report_has_started_finished(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 + executor = DryRunExecutor(sample_exchange_quotes) + report = execute_plan(plan, executor) + assert report.started_at != "" + assert report.finished_at != "" + assert "T" in report.started_at + assert "T" in report.finished_at + + def test_now_iso_format(self): + ts = _now_iso() + assert "T" in ts + assert "+" in ts or "Z" in ts # timezone info + + +class TestOrderMonitoring: + """Test _monitor_order polling and timeout behavior.""" + + def test_immediate_fill(self, sample_exchange_quotes): + """DryRunExecutor always returns 'simulated' = terminal.""" + executor = DryRunExecutor(sample_exchange_quotes) + status = _monitor_order(executor, "dry-123", timeout_seconds=5.0) + assert status == "simulated" + + def test_timeout_triggers_cancel(self): + """Executor that always returns 'open' should timeout and cancel.""" + class StuckExecutor(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, order_id): return "open" + def cancel_order(self, order_id): return True + + executor = StuckExecutor() + status = _monitor_order(executor, "stuck-123", timeout_seconds=0.1, poll_interval=0.05) + assert status == "timeout" + + def test_delayed_fill(self): + """Executor that fills after a few polls.""" + call_count = {"n": 0} + + class DelayedExecutor(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, order_id): + call_count["n"] += 1 + if call_count["n"] >= 3: + return "filled" + return "open" + def cancel_order(self, order_id): return True + + executor = DelayedExecutor() + status = _monitor_order(executor, "delay-123", timeout_seconds=5.0, poll_interval=0.01) + assert status == "filled" + assert call_count["n"] >= 3 + + def test_rejected_is_terminal(self): + """Executor returning 'rejected' should stop immediately.""" + class RejectExecutor(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, order_id): return "rejected" + def cancel_order(self, order_id): return True + + executor = RejectExecutor() + status = _monitor_order(executor, "rej-123", timeout_seconds=5.0) + assert status == "rejected" + + +class TestAutoCancel: + """Test _cancel_filled_orders cancellation logic.""" + + def test_cancels_filled_orders(self): + results = [ + OrderResult("id-1", "filled", 100.0, 1, "X", "BUY", "deribit"), + OrderResult("id-2", "filled", 200.0, 1, "Y", "SELL", "aevo"), + ] + cancel_log = [] + + class TrackingExecutor(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, order_id): return "filled" + def cancel_order(self, order_id): + cancel_log.append(order_id) + return True + + tracker = TrackingExecutor() + cancelled = _cancel_filled_orders(results, lambda ex: tracker) + assert cancelled == ["id-1", "id-2"] + assert cancel_log == ["id-1", "id-2"] + + def test_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"), + ] + tracker = DryRunExecutor([]) + cancelled = _cancel_filled_orders(results, lambda ex: tracker) + assert cancelled == ["id-2"] + + def test_empty_results(self): + cancelled = _cancel_filled_orders([], lambda ex: DryRunExecutor([])) + assert cancelled == [] + + def test_cancel_failure_skips(self): + """If cancel_order returns False, order ID not in cancelled list.""" + results = [ + OrderResult("id-1", "filled", 100.0, 1, "X", "BUY", "deribit"), + ] + + class FailCancelExecutor(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, order_id): return "filled" + def cancel_order(self, order_id): return False + + cancelled = _cancel_filled_orders(results, lambda ex: FailCancelExecutor()) + assert cancelled == [] + + +class TestExecutionSavings: + """Test compute_execution_savings comparison logic.""" + + def test_savings_when_cheaper(self, btc_option_data): + """When execution price < Synth price, savings should be positive.""" + plan = ExecutionPlan( + strategy_description="Test", 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")], + ) + savings = compute_execution_savings(plan, btc_option_data) + # Synth price for 67500 call = 638.43, exec = 600 → savings = 38.43 + assert savings["savings_usd"] > 0 + assert savings["synth_theoretical_cost"] == pytest.approx(638.43) + assert savings["execution_cost"] == pytest.approx(600.0) + + def test_no_savings_at_synth_price(self, btc_option_data): + """When execution price = Synth price, savings = 0.""" + plan = ExecutionPlan( + strategy_description="Test", 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")], + ) + savings = compute_execution_savings(plan, btc_option_data) + assert savings["savings_usd"] == pytest.approx(0.0) + + def test_multi_leg_savings(self, btc_option_data): + """Multi-leg spread: savings on net cost.""" + plan = ExecutionPlan( + strategy_description="Test spread", 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"), + ], + ) + savings = compute_execution_savings(plan, btc_option_data) + # Synth: 987.04 - 373.27 = 613.77, Exec: 950 - 390 = 560 → savings ~53.77 + assert savings["savings_usd"] > 0 + assert "savings_pct" in savings + + +class TestExecutePlanWithTimeout: + """Test execute_plan timeout_seconds parameter.""" + + def test_timeout_not_used_for_simulated(self, sample_exchange_quotes): + """Dry-run fills are 'simulated', never 'open', so timeout monitoring doesn't trigger.""" + plan = ExecutionPlan( + strategy_description="Test", 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, + ) + executor = DryRunExecutor(sample_exchange_quotes) + report = execute_plan(plan, executor, timeout_seconds=5.0) + assert report.all_filled is True + # Status should still be simulated, not changed by monitoring + assert report.results[0].status == "simulated" + + def test_timeout_triggers_for_open_orders(self): + """Executor that returns 'open' status should trigger monitoring → timeout.""" + class OpenExecutor(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): + return OrderResult( + order_id="open-123", status="open", fill_price=0.0, + fill_quantity=0, instrument=order.instrument, + action=order.action, exchange="test", + ) + def get_order_status(self, order_id): return "open" + def cancel_order(self, order_id): return True + + plan = ExecutionPlan( + strategy_description="Test", 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) + # Should timeout and fail + assert report.all_filled is False + assert report.results[0].status == "timeout" + + def test_plan_timeout_default(self): + """ExecutionPlan has default timeout_seconds=30.""" + plan = ExecutionPlan( + strategy_description="Test", strategy_type="long_call", + exchange="deribit", asset="BTC", expiry="", + ) + assert plan.timeout_seconds == 30.0 + + +class TestAevoSigning: + """Test Aevo HMAC-SHA256 4-part signing.""" + + def test_sign_uses_four_parts(self): + """Signature message = timestamp + method + path + body.""" + import hashlib, hmac as hmac_mod + executor = AevoExecutor("test-key", "test-secret", testnet=True) + sig = executor._sign("12345", "POST", "/orders", '{"side":"buy"}') + expected_msg = '12345POST/orders{"side":"buy"}' + expected_sig = hmac_mod.new( + b"test-secret", expected_msg.encode(), hashlib.sha256, + ).hexdigest() + assert sig == expected_sig + + def test_headers_include_all_fields(self): + executor = AevoExecutor("my-key", "my-secret", testnet=True) + headers = executor._headers("POST", "/orders", '{"side":"buy"}') + assert headers["AEVO-KEY"] == "my-key" + assert "AEVO-TIMESTAMP" in headers + assert "AEVO-SIGNATURE" in headers + assert headers["Content-Type"] == "application/json" + + def test_sign_empty_body(self): + """GET request with empty body still produces valid signature.""" + executor = AevoExecutor("k", "s", testnet=True) + sig = executor._sign("999", "GET", "/orders/123", "") + assert len(sig) == 64 # SHA-256 hex digest + + +class TestExecutionLogEnhanced: + """Verify execution log includes new fields.""" + + def test_log_contains_timestamps_and_latency(self, sample_strategy, sample_exchange_quotes, + btc_option_data, tmp_path): + import json + scored = _make_scored(sample_strategy) + plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) + plan.dry_run = True + executor = DryRunExecutor(sample_exchange_quotes) + report = execute_plan(plan, executor) + + log_path = str(tmp_path / "enhanced_log.json") + save_execution_log(report, log_path) + + with open(log_path) as f: + log = json.load(f) + + assert log["started_at"] != "" + assert log["finished_at"] != "" + assert isinstance(log["cancelled_orders"], list) + assert log["fills"][0]["timestamp"] != "" + assert log["fills"][0]["latency_ms"] >= 0 + + +class TestPartialFillAutoCancel: + """Test that execute_plan auto-cancels on partial failure.""" + + def test_second_leg_failure_cancels_first(self, sample_exchange_quotes): + """When second order fails, first filled order gets cancelled.""" + call_count = {"n": 0} + + class PartialExecutor(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, order_id): return "filled" + def cancel_order(self, order_id): return True + + plan = ExecutionPlan( + strategy_description="Test spread", 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, PartialExecutor()) + assert report.all_filled is False + assert "Partial fill" in report.summary + assert len(report.cancelled_orders) > 0 diff --git a/tools/options-gps/tests/test_executor_e2e.py b/tools/options-gps/tests/test_executor_e2e.py index c229252..a9911ff 100644 --- a/tools/options-gps/tests/test_executor_e2e.py +++ b/tools/options-gps/tests/test_executor_e2e.py @@ -159,6 +159,102 @@ def test_non_crypto_skips_execution(): 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, main.py should block live execution. + We test the logic directly: if is_live and no_trade_reason and not force → block.""" + # This is a logic-level test since we can't easily run the full CLI here + no_trade_reason = "Countermove detected" + is_live = True + force = False + blocked = is_live and no_trade_reason and not force + assert blocked is True + + +def test_guardrail_allows_force(): + """With --force, live execution proceeds despite no_trade_reason.""" + no_trade_reason = "Countermove detected" + is_live = True + force = True + blocked = is_live and no_trade_reason and not force + assert blocked is False + + +def test_guardrail_allows_dry_run(): + """Dry-run ignores guardrail (not live execution).""" + no_trade_reason = "Low confidence" + is_live = False + force = False + blocked = is_live and no_trade_reason and not force + assert blocked is False + + +def test_guardrail_no_reason_allows_live(): + """When no_trade_reason is None, live execution proceeds.""" + no_trade_reason = None + is_live = True + force = False + blocked = is_live and no_trade_reason and not force + assert not blocked + + if __name__ == "__main__": test_full_execution_pipeline() print("PASS: test_full_execution_pipeline") @@ -166,4 +262,16 @@ def test_non_crypto_skips_execution(): 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.") From f155ff07f754c3d79ade0aea77358b9b2aff69f4 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Fri, 13 Mar 2026 23:26:42 +0100 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20Deribit=20USD=E2=86=92BTC=20convers?= =?UTF-8?q?ion,=20stateful=20DryRunExecutor,=20--screen=20none?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deribit price conversion: _get_index_price() fetches USD index (cached), _usd_to_btc() converts pipeline USD prices, _get_book_price() snaps to live order book, _align_tick() rounds to 0.0005 BTC tick size - Stateful DryRunExecutor: tracks placed orders in _orders dict, get_order_status() returns stored OrderResult, cancel_order() transitions state to "cancelled", unknown IDs return "not_found" - get_order_status() returns OrderResult across all executors (richer type) - _monitor_order() returns OrderResult with fill data, updates result in execute_plan with fill_price/fill_quantity from monitoring - --screen none/0: skip all analysis screens, show only execution (Screen 5) - _refuse_execution(): extracted testable guardrail function - 230 tests passing (20 new): tick alignment, USD→BTC, stateful executor, screen parsing, refuse_execution, OrderResult return type --- tools/options-gps/README.md | 2 +- tools/options-gps/executor.py | 150 ++++++++++-- tools/options-gps/main.py | 24 +- tools/options-gps/tests/test_executor.py | 226 +++++++++++++++++-- tools/options-gps/tests/test_executor_e2e.py | 40 ++-- 5 files changed, 366 insertions(+), 76 deletions(-) diff --git a/tools/options-gps/README.md b/tools/options-gps/README.md index 9391c8b..cc18e71 100644 --- a/tools/options-gps/README.md +++ b/tools/options-gps/README.md @@ -60,7 +60,7 @@ Execution is supported only for **crypto assets** (BTC, ETH, SOL). Use `--execut **Exchange protocols:** -- **Deribit**: JSON-RPC 2.0 over POST with Bearer token auth. Uses `contracts` parameter for unambiguous option sizing. Retries on transient errors (429, 502, 503, timeout) with exponential backoff. +- **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. diff --git a/tools/options-gps/executor.py b/tools/options-gps/executor.py index 78da112..cf41ae2 100644 --- a/tools/options-gps/executor.py +++ b/tools/options-gps/executor.py @@ -150,7 +150,7 @@ def place_order(self, order: OrderRequest) -> OrderResult: ... @abstractmethod - def get_order_status(self, order_id: str) -> str: + def get_order_status(self, order_id: str) -> OrderResult: ... @abstractmethod @@ -159,10 +159,12 @@ def cancel_order(self, order_id: str) -> bool: class DryRunExecutor(BaseExecutor): - """Simulates order execution using exchange quote data. No network calls.""" + """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 @@ -171,13 +173,15 @@ def place_order(self, order: OrderRequest) -> OrderResult: t0 = time.monotonic() ts = _now_iso() if not order.strike or not order.option_type: - return OrderResult( + 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, ) @@ -187,7 +191,7 @@ def place_order(self, order: OrderRequest) -> OrderResult: 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 - return OrderResult( + result = OrderResult( order_id=f"dry-{uuid.uuid4().hex[:8]}", status="simulated", fill_price=fill_price, @@ -199,19 +203,33 @@ def place_order(self, order: OrderRequest) -> OrderResult: timestamp=ts, latency_ms=round(latency, 2), ) + self._orders[result.order_id] = result + return result - def get_order_status(self, order_id: str) -> str: - return "simulated" + 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: - return True + 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 @@ -222,6 +240,7 @@ def __init__(self, client_id: str, client_secret: str, testnet: bool = False): ) 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 @@ -249,6 +268,49 @@ def _call(): 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. None on failure.""" + try: + result = self._rpc("public/get_order_book", { + "instrument_name": instrument, "depth": 1, + }) + if action == "BUY": + return float(result.get("best_ask_price", 0)) or None + return float(result.get("best_bid_price", 0)) or 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 @@ -266,6 +328,16 @@ def authenticate(self) -> bool: 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, @@ -273,7 +345,7 @@ def place_order(self, order: OrderRequest) -> OrderResult: "type": order.order_type, } if order.order_type == "limit": - params["price"] = order.price + params["price"] = price_btc try: result = self._rpc(method, params) latency = (time.monotonic() - t0) * 1000 @@ -301,12 +373,23 @@ def place_order(self, order: OrderRequest) -> OrderResult: timestamp=ts, latency_ms=round(latency, 2), ) - def get_order_status(self, order_id: str) -> str: + def get_order_status(self, order_id: str) -> OrderResult: try: result = self._rpc("private/get_order_state", {"order_id": order_id}) - return result.get("order_state", "unknown") + 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 "unknown" + 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: @@ -396,7 +479,7 @@ def _call(): timestamp=ts, latency_ms=round(latency, 2), ) - def get_order_status(self, order_id: str) -> str: + def get_order_status(self, order_id: str) -> OrderResult: try: resp = requests.get( f"{self.base_url}/orders/{order_id}", @@ -404,9 +487,21 @@ def get_order_status(self, order_id: str) -> str: timeout=10, ) resp.raise_for_status() - return resp.json().get("status", "unknown") + 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", ""), + action=data.get("side", "").upper(), + exchange="aevo", + ) except Exception: - return "unknown" + 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: @@ -442,21 +537,25 @@ def check_slippage(result: OrderResult, max_slippage_pct: float) -> bool: def _monitor_order(executor: BaseExecutor, order_id: str, timeout_seconds: float = 30.0, - poll_interval: float = 1.0) -> str: + poll_interval: float = 1.0) -> OrderResult: """Poll order status until terminal state or timeout. - Returns final status. On timeout, attempts to cancel the order.""" + 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: - status = executor.get_order_status(order_id) - if status in ("filled", "rejected", "cancelled", "error", "simulated"): - return status + 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 "timeout" + 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], @@ -613,12 +712,19 @@ def _get_exec(exchange: str) -> BaseExecutor | None: # Monitor open orders until filled or timeout if (use_timeout and result.status == "open" and result.order_id): - final_status = _monitor_order( + monitored = _monitor_order( ex, result.order_id, timeout_seconds=timeout_seconds, poll_interval=1.0, ) - result.status = final_status + 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) diff --git a/tools/options-gps/main.py b/tools/options-gps/main.py index 616b3b6..0acbf6e 100644 --- a/tools/options-gps/main.py +++ b/tools/options-gps/main.py @@ -808,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() @@ -819,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", @@ -827,7 +839,7 @@ 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", @@ -945,9 +957,9 @@ def main(): if is_executing and exec_card is not None and exchange_quotes: # Guardrail: refuse live execution when no-trade unless --force - if is_live and no_trade_reason and not args.force: - print(f"\n Guardrail: {no_trade_reason}") - print(f" Use --force to override and execute anyway.") + 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) diff --git a/tools/options-gps/tests/test_executor.py b/tools/options-gps/tests/test_executor.py index d659573..cc0bf8c 100644 --- a/tools/options-gps/tests/test_executor.py +++ b/tools/options-gps/tests/test_executor.py @@ -36,6 +36,12 @@ 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): """Wrap a StrategyCandidate into a ScoredStrategy for testing.""" return ScoredStrategy( @@ -242,13 +248,50 @@ def test_no_matching_quote_uses_order_price(self, sample_exchange_quotes): assert result.status == "simulated" assert result.fill_price == 100.0 - def test_get_order_status(self, sample_exchange_quotes): + def test_get_order_status_after_place(self, sample_exchange_quotes): + """After placing an order, get_order_status returns the OrderResult.""" + 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 result.status == "simulated" + assert result.order_id == placed.order_id + assert result.fill_price > 0 + + def test_get_order_status_unknown_id(self, sample_exchange_quotes): + """Unknown order ID returns not_found status.""" + executor = DryRunExecutor(sample_exchange_quotes) + result = executor.get_order_status("nonexistent-id") + assert result.status == "not_found" + + def test_cancel_order_after_place(self, sample_exchange_quotes): + """Cancelling a placed order transitions it to cancelled.""" + 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) + assert executor.cancel_order(placed.order_id) is True + result = executor.get_order_status(placed.order_id) + assert result.status == "cancelled" + + def test_cancel_unknown_order(self, sample_exchange_quotes): + """Cancelling an unknown order returns False.""" executor = DryRunExecutor(sample_exchange_quotes) - assert executor.get_order_status("any-id") == "simulated" + assert executor.cancel_order("nonexistent-id") is False - def test_cancel_order(self, sample_exchange_quotes): + def test_stateful_tracks_multiple_orders(self, sample_exchange_quotes): + """Multiple placed orders are all tracked independently.""" executor = DryRunExecutor(sample_exchange_quotes) - assert executor.cancel_order("any-id") is True + order1 = OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, + strike=67500, option_type="call") + order2 = OrderRequest("BTC-67500-P", "SELL", 1, "limit", 300.0, "dry_run", 1, + strike=67500, option_type="put") + r1 = executor.place_order(order1) + r2 = executor.place_order(order2) + 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" class TestExecuteFlow: @@ -592,22 +635,26 @@ class TestOrderMonitoring: """Test _monitor_order polling and timeout behavior.""" def test_immediate_fill(self, sample_exchange_quotes): - """DryRunExecutor always returns 'simulated' = terminal.""" + """DryRunExecutor returns 'simulated' = terminal. Monitor returns OrderResult.""" executor = DryRunExecutor(sample_exchange_quotes) - status = _monitor_order(executor, "dry-123", timeout_seconds=5.0) - assert status == "simulated" + order = OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, + strike=67500, option_type="call") + placed = executor.place_order(order) + result = _monitor_order(executor, placed.order_id, timeout_seconds=5.0) + assert result.status == "simulated" + assert result.order_id == placed.order_id def test_timeout_triggers_cancel(self): """Executor that always returns 'open' should timeout and cancel.""" class StuckExecutor(BaseExecutor): def authenticate(self): return True def place_order(self, order): pass - def get_order_status(self, order_id): return "open" + def get_order_status(self, order_id): return _status_result(order_id, "open") def cancel_order(self, order_id): return True executor = StuckExecutor() - status = _monitor_order(executor, "stuck-123", timeout_seconds=0.1, poll_interval=0.05) - assert status == "timeout" + result = _monitor_order(executor, "stuck-123", timeout_seconds=0.1, poll_interval=0.05) + assert result.status == "timeout" def test_delayed_fill(self): """Executor that fills after a few polls.""" @@ -619,13 +666,15 @@ def place_order(self, order): pass def get_order_status(self, order_id): call_count["n"] += 1 if call_count["n"] >= 3: - return "filled" - return "open" + return OrderResult(order_id=order_id, status="filled", + fill_price=100.0, fill_quantity=1, + instrument="X", action="BUY", exchange="test") + return _status_result(order_id, "open") def cancel_order(self, order_id): return True executor = DelayedExecutor() - status = _monitor_order(executor, "delay-123", timeout_seconds=5.0, poll_interval=0.01) - assert status == "filled" + result = _monitor_order(executor, "delay-123", timeout_seconds=5.0, poll_interval=0.01) + assert result.status == "filled" assert call_count["n"] >= 3 def test_rejected_is_terminal(self): @@ -633,12 +682,27 @@ def test_rejected_is_terminal(self): class RejectExecutor(BaseExecutor): def authenticate(self): return True def place_order(self, order): pass - def get_order_status(self, order_id): return "rejected" + def get_order_status(self, order_id): return _status_result(order_id, "rejected") def cancel_order(self, order_id): return True executor = RejectExecutor() - status = _monitor_order(executor, "rej-123", timeout_seconds=5.0) - assert status == "rejected" + result = _monitor_order(executor, "rej-123", timeout_seconds=5.0) + assert result.status == "rejected" + + def test_monitor_returns_fill_data(self): + """Monitor should return OrderResult with fill data when available.""" + class FillExecutor(BaseExecutor): + def authenticate(self): return True + def place_order(self, order): pass + def get_order_status(self, order_id): + return OrderResult(order_id=order_id, status="filled", + fill_price=650.0, fill_quantity=1, + instrument="BTC-67500-C", action="BUY", exchange="deribit") + def cancel_order(self, order_id): return True + + result = _monitor_order(FillExecutor(), "fill-1", timeout_seconds=5.0) + assert result.fill_price == 650.0 + assert result.fill_quantity == 1 class TestAutoCancel: @@ -654,7 +718,7 @@ def test_cancels_filled_orders(self): class TrackingExecutor(BaseExecutor): def authenticate(self): return True def place_order(self, order): pass - def get_order_status(self, order_id): return "filled" + def get_order_status(self, order_id): return _status_result(order_id, "filled") def cancel_order(self, order_id): cancel_log.append(order_id) return True @@ -669,8 +733,11 @@ def test_skips_non_filled(self): OrderResult("id-1", "error", 0.0, 0, "X", "BUY", "deribit"), OrderResult("id-2", "filled", 200.0, 1, "Y", "SELL", "aevo"), ] - tracker = DryRunExecutor([]) - cancelled = _cancel_filled_orders(results, lambda ex: tracker) + # DryRunExecutor cancel returns False for unknown IDs now (stateful) + executor = DryRunExecutor([]) + # Manually add the order so cancel works + executor._orders["id-2"] = results[1] + cancelled = _cancel_filled_orders(results, lambda ex: executor) assert cancelled == ["id-2"] def test_empty_results(self): @@ -686,7 +753,7 @@ def test_cancel_failure_skips(self): class FailCancelExecutor(BaseExecutor): def authenticate(self): return True def place_order(self, order): pass - def get_order_status(self, order_id): return "filled" + def get_order_status(self, order_id): return _status_result(order_id, "filled") def cancel_order(self, order_id): return False cancelled = _cancel_filled_orders(results, lambda ex: FailCancelExecutor()) @@ -767,7 +834,7 @@ def place_order(self, order): fill_quantity=0, instrument=order.instrument, action=order.action, exchange="test", ) - def get_order_status(self, order_id): return "open" + def get_order_status(self, order_id): return _status_result(order_id, "open") def cancel_order(self, order_id): return True plan = ExecutionPlan( @@ -866,7 +933,7 @@ def place_order(self, order): fill_quantity=0, instrument=order.instrument, action=order.action, exchange="test", error="rejected", ) - def get_order_status(self, order_id): return "filled" + def get_order_status(self, order_id): return _status_result(order_id, "filled") def cancel_order(self, order_id): return True plan = ExecutionPlan( @@ -883,3 +950,116 @@ def cancel_order(self, order_id): return True assert report.all_filled is False assert "Partial fill" in report.summary assert len(report.cancelled_orders) > 0 + + +class TestDeribitPriceConversion: + """Test Deribit USD→BTC conversion, tick alignment, and order book snapping.""" + + def test_align_tick_basic(self): + from executor import DeribitExecutor + # 0.00973 → nearest 0.0005 = 0.0095 (rounds down) + assert DeribitExecutor._align_tick(0.00973, 0.0005) == 0.0095 + assert DeribitExecutor._align_tick(0.00950, 0.0005) == 0.0095 + # 0.00975 → 0.01 (rounds to nearest) + assert DeribitExecutor._align_tick(0.00975, 0.0005) == 0.01 + + def test_align_tick_exact(self): + from executor import DeribitExecutor + assert DeribitExecutor._align_tick(0.0100, 0.0005) == 0.01 + + def test_align_tick_zero_tick_size(self): + from executor import DeribitExecutor + assert DeribitExecutor._align_tick(0.00973, 0) == 0.00973 + + def test_usd_to_btc_conversion(self): + """USD price / index price → BTC price, aligned to tick.""" + from executor import DeribitExecutor + executor = DeribitExecutor("id", "secret", testnet=True) + executor._index_cache["BTC"] = 67000.0 + btc_price = executor._usd_to_btc(670.0, "BTC") + # 670 / 67000 = 0.01 exactly + assert btc_price == 0.01 + + def test_usd_to_btc_fallback_no_index(self): + """When index is 0/unavailable, returns USD price as-is.""" + from executor import DeribitExecutor + executor = DeribitExecutor("id", "secret", testnet=True) + # No index cached, _get_index_price will fail (no network) + executor._index_cache["BTC"] = 0.0 + btc_price = executor._usd_to_btc(670.0, "BTC") + assert btc_price == 670.0 + + def test_index_cache_reuse(self): + """Index price is cached after first call.""" + from executor import DeribitExecutor + executor = DeribitExecutor("id", "secret", testnet=True) + executor._index_cache["ETH"] = 3500.0 + # Second call should return cached value + assert executor._get_index_price("ETH") == 3500.0 + + +class TestScreenParseNone: + """Test --screen none/0 support.""" + + def test_screen_none(self): + import sys, os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + 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_screen_subset(self): + from main import _parse_screen_arg + assert _parse_screen_arg("1,3") == {1, 3} + + +class TestRefuseExecution: + """Test _refuse_execution guardrail function.""" + + def test_refuses_live_with_no_trade(self): + from main import _refuse_execution + result = _refuse_execution(True, "Countermove detected", False) + assert result is not None + assert "Guardrail" in result + + def test_allows_with_force(self): + from main import _refuse_execution + result = _refuse_execution(True, "Countermove detected", True) + assert result is None + + def test_allows_dry_run(self): + from main import _refuse_execution + result = _refuse_execution(False, "Countermove detected", False) + assert result is None + + def test_allows_no_reason(self): + from main import _refuse_execution + result = _refuse_execution(True, None, False) + assert result is None + + +class TestGetOrderStatusReturnsOrderResult: + """Verify get_order_status returns OrderResult across all executors.""" + + def test_dry_run_returns_order_result(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" + assert result.fill_price > 0 + + def test_dry_run_not_found_returns_order_result(self, sample_exchange_quotes): + executor = DryRunExecutor(sample_exchange_quotes) + result = executor.get_order_status("nonexistent") + assert isinstance(result, OrderResult) + assert result.status == "not_found" diff --git a/tools/options-gps/tests/test_executor_e2e.py b/tools/options-gps/tests/test_executor_e2e.py index a9911ff..04c7bfb 100644 --- a/tools/options-gps/tests/test_executor_e2e.py +++ b/tools/options-gps/tests/test_executor_e2e.py @@ -218,41 +218,33 @@ def test_execution_report_timing(): def test_guardrail_blocks_live_on_no_trade(): - """When no_trade_reason is active, main.py should block live execution. - We test the logic directly: if is_live and no_trade_reason and not force → block.""" - # This is a logic-level test since we can't easily run the full CLI here - no_trade_reason = "Countermove detected" - is_live = True - force = False - blocked = is_live and no_trade_reason and not force - assert blocked is True + """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, live execution proceeds despite no_trade_reason.""" - no_trade_reason = "Countermove detected" - is_live = True - force = True - blocked = is_live and no_trade_reason and not force - assert blocked is False + """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).""" - no_trade_reason = "Low confidence" - is_live = False - force = False - blocked = is_live and no_trade_reason and not force - assert blocked is False + 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.""" - no_trade_reason = None - is_live = True - force = False - blocked = is_live and no_trade_reason and not force - assert not blocked + from main import _refuse_execution + result = _refuse_execution(True, None, False) + assert result is None if __name__ == "__main__": From 92ed86972386194240870f8974f82d12edfa47a6 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Fri, 13 Mar 2026 23:43:02 +0100 Subject: [PATCH 4/6] fix: add mark_price fallback in Deribit _get_book_price When best_ask/best_bid is unavailable (empty order book), falls back to mark_price from Deribit order book response before returning None. --- tools/options-gps/executor.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/options-gps/executor.py b/tools/options-gps/executor.py index cf41ae2..ada090c 100644 --- a/tools/options-gps/executor.py +++ b/tools/options-gps/executor.py @@ -285,14 +285,21 @@ def _get_index_price(self, asset: str) -> float: 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. None on failure.""" + 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": - return float(result.get("best_ask_price", 0)) or None - return float(result.get("best_bid_price", 0)) or None + 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 From 8de39b364c91f2a5fde5960a1a1f5726d5f1d49c Mon Sep 17 00:00:00 2001 From: eureka928 Date: Fri, 13 Mar 2026 23:55:53 +0100 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20consolidate=20executor=20tests?= =?UTF-8?q?=20(24=20classes=20=E2=86=92=2014,=201065=20=E2=86=92=20591=20l?= =?UTF-8?q?ines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge related test classes, remove redundant coverage, trim edge-case tests already covered by integration tests. 83 tests, 211 total suite. --- tools/options-gps/tests/test_executor.py | 1082 ++++++---------------- 1 file changed, 304 insertions(+), 778 deletions(-) diff --git a/tools/options-gps/tests/test_executor.py b/tools/options-gps/tests/test_executor.py index cc0bf8c..ca22864 100644 --- a/tools/options-gps/tests/test_executor.py +++ b/tools/options-gps/tests/test_executor.py @@ -1,6 +1,9 @@ """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 @@ -8,15 +11,14 @@ import requests import pytest -from unittest.mock import Mock, patch from executor import ( OrderRequest, OrderResult, ExecutionPlan, - ExecutionReport, BaseExecutor, DryRunExecutor, AevoExecutor, + DeribitExecutor, deribit_instrument_name, aevo_instrument_name, build_execution_plan, @@ -30,7 +32,6 @@ _is_retryable, _monitor_order, _cancel_filled_orders, - _now_iso, ) from exchange import _parse_instrument_key from pipeline import ScoredStrategy @@ -43,307 +44,305 @@ def _status_result(order_id: str, status: str) -> OrderResult: def _make_scored(strategy): - """Wrap a StrategyCandidate into a ScoredStrategy for testing.""" 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", + 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_instrument_name(self): - name = deribit_instrument_name("BTC", "2026-02-26T08:00:00Z", 67500, "Call") - assert name == "BTC-26FEB26-67500-C" + def test_deribit_call(self): + assert deribit_instrument_name("BTC", "2026-02-26T08:00:00Z", 67500, "Call") == "BTC-26FEB26-67500-C" - def test_deribit_instrument_name_put(self): - name = deribit_instrument_name("ETH", "2026-03-15T08:00:00Z", 4000, "Put") - assert name == "ETH-15MAR26-4000-P" + def test_deribit_put(self): + assert deribit_instrument_name("ETH", "2026-03-15T08:00:00Z", 4000, "Put") == "ETH-15MAR26-4000-P" - def test_aevo_instrument_name(self): - name = aevo_instrument_name("BTC", 67500, "Call") - assert name == "BTC-67500-C" + def test_aevo_call(self): + assert aevo_instrument_name("BTC", 67500, "Call") == "BTC-67500-C" - def test_aevo_instrument_name_put(self): - name = aevo_instrument_name("SOL", 150, "Put") - assert name == "SOL-150-P" + def test_aevo_put(self): + assert aevo_instrument_name("SOL", 150, "Put") == "SOL-150-P" def test_deribit_roundtrip(self): - """Build a Deribit name then parse it back — should recover strike and type.""" name = deribit_instrument_name("BTC", "2026-02-26T08:00:00Z", 67500, "Call") - parsed = _parse_instrument_key(name) - assert parsed is not None - strike, opt_type = parsed - assert strike == 67500 - assert opt_type == "call" + strike, opt_type = _parse_instrument_key(name) + assert strike == 67500 and opt_type == "call" def test_aevo_roundtrip(self): - """Build an Aevo name then parse it back — should recover strike and type.""" name = aevo_instrument_name("BTC", 68000, "Put") - parsed = _parse_instrument_key(name) - assert parsed is not None - strike, opt_type = parsed - assert strike == 68000 - assert opt_type == "put" + strike, opt_type = _parse_instrument_key(name) + assert strike == 68000 and opt_type == "put" def test_deribit_empty_expiry(self): - """Empty expiry falls back to UNKNOWN date part.""" - name = deribit_instrument_name("BTC", "", 67500, "Call") - assert "UNKNOWN" in name + 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 - assert plan.orders[0].action == "BUY" - assert plan.orders[0].exchange == "deribit" - assert plan.orders[0].strike == 67500 - assert plan.orders[0].option_type == "call" - assert "67500" in plan.orders[0].instrument - assert plan.estimated_cost > 0 + 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 "BUY" in actions - assert "SELL" in actions - - def test_exchange_override(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): - """--exchange deribit → all orders use Deribit names.""" - scored = _make_scored(multi_leg_strategy) - plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) - for order in plan.orders: - assert order.exchange == "deribit" - assert "BTC-" in order.instrument + 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): - """--exchange aevo → Aevo instrument names (no date).""" scored = _make_scored(sample_strategy) plan = build_execution_plan(scored, "BTC", "aevo", sample_exchange_quotes, btc_option_data) - assert len(plan.orders) == 1 assert plan.orders[0].instrument == "BTC-67500-C" def test_auto_route(self, sample_strategy, sample_exchange_quotes, btc_option_data): - """exchange=None → auto-routes via leg_divergences.""" scored = _make_scored(sample_strategy) plan = build_execution_plan(scored, "BTC", None, sample_exchange_quotes, btc_option_data) assert plan.exchange == "auto" - assert len(plan.orders) == 1 - # Should pick a valid exchange assert plan.orders[0].exchange in ("deribit", "aevo") - def test_estimated_cost_multi_leg(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): - """estimated_cost = buy prices - sell prices.""" + 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) - 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") - assert plan.estimated_cost == pytest.approx(buy_total - sell_total) + 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="Test", 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")], + 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")], ) - valid, err = validate_plan(plan) - assert valid is True - assert err == "" + assert validate_plan(plan) == (True, "") def test_empty_orders(self): - plan = ExecutionPlan( - strategy_description="Test", strategy_type="long_call", - exchange="deribit", asset="BTC", expiry="", - ) + plan = ExecutionPlan(strategy_description="T", strategy_type="long_call", exchange="deribit", asset="BTC", expiry="") valid, err = validate_plan(plan) - assert valid is False - assert "No orders" in err + assert not valid and "No orders" in err def test_zero_price(self): - plan = ExecutionPlan( - strategy_description="Test", strategy_type="long_call", - exchange="deribit", asset="BTC", expiry="", - orders=[OrderRequest("BTC-26FEB26-67500-C", "BUY", 1, "limit", 0.0, "deribit", 0, - strike=67500, option_type="call")], - ) - valid, err = validate_plan(plan) - assert valid is False - assert "price" in err.lower() + 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="Test", strategy_type="long_call", - exchange="deribit", asset="BTC", expiry="", - orders=[OrderRequest("BTC-26FEB26-67500-C", "BUY", 0, "limit", 660.0, "deribit", 0, - strike=67500, option_type="call")], - ) - valid, err = validate_plan(plan) - assert valid is False - assert "quantity" in err.lower() + 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="Test", strategy_type="long_call", - exchange="deribit", asset="BTC", expiry="", - orders=[OrderRequest("", "BUY", 1, "limit", 660.0, "deribit", 0, - strike=67500, option_type="call")], - ) - valid, err = validate_plan(plan) - assert valid is False - assert "instrument" in err.lower() + 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): - executor = DryRunExecutor(sample_exchange_quotes) - assert executor.authenticate() is True + assert DryRunExecutor(sample_exchange_quotes).authenticate() is True def test_place_buy(self, sample_exchange_quotes): - """BUY fills at best ask from quotes.""" executor = DryRunExecutor(sample_exchange_quotes) - order = OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, - strike=67500, option_type="call") - result = executor.place_order(order) - assert result.status == "simulated" - assert result.fill_quantity == 1 - assert result.fill_price == 655.0 # best ask from aevo + 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): - """SELL fills at best bid from quotes.""" executor = DryRunExecutor(sample_exchange_quotes) - order = OrderRequest("BTC-67500-C", "SELL", 1, "limit", 620.0, "dry_run", 0, - strike=67500, option_type="call") - result = executor.place_order(order) - assert result.status == "simulated" - assert result.fill_quantity == 1 - assert result.fill_price == 620.0 # best bid from aevo + 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(self, sample_exchange_quotes): - """Order without strike/option_type returns error.""" - executor = DryRunExecutor(sample_exchange_quotes) - order = OrderRequest("INVALID", "BUY", 1, "limit", 100.0, "dry_run", 0) - result = executor.place_order(order) - assert result.status == "error" + 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_uses_order_price(self, sample_exchange_quotes): - """When no exchange quote matches, falls back to order limit price.""" - executor = DryRunExecutor(sample_exchange_quotes) - order = OrderRequest("BTC-99999-C", "BUY", 1, "limit", 100.0, "dry_run", 0, - strike=99999, option_type="call") - result = executor.place_order(order) - assert result.status == "simulated" - assert result.fill_price == 100.0 + 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_get_order_status_after_place(self, sample_exchange_quotes): - """After placing an order, get_order_status returns the OrderResult.""" + 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") + 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 result.status == "simulated" - assert result.order_id == placed.order_id - assert result.fill_price > 0 + assert isinstance(result, OrderResult) + assert result.status == "simulated" and result.order_id == placed.order_id - def test_get_order_status_unknown_id(self, sample_exchange_quotes): - """Unknown order ID returns not_found status.""" - executor = DryRunExecutor(sample_exchange_quotes) - result = executor.get_order_status("nonexistent-id") - assert result.status == "not_found" + 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_order_after_place(self, sample_exchange_quotes): - """Cancelling a placed order transitions it to cancelled.""" + def test_cancel_transitions_state(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) + 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 - result = executor.get_order_status(placed.order_id) - assert result.status == "cancelled" + assert executor.get_order_status(placed.order_id).status == "cancelled" - def test_cancel_unknown_order(self, sample_exchange_quotes): - """Cancelling an unknown order returns False.""" - executor = DryRunExecutor(sample_exchange_quotes) - assert executor.cancel_order("nonexistent-id") is False + def test_cancel_unknown_returns_false(self, sample_exchange_quotes): + assert DryRunExecutor(sample_exchange_quotes).cancel_order("nope") is False - def test_stateful_tracks_multiple_orders(self, sample_exchange_quotes): - """Multiple placed orders are all tracked independently.""" + def test_tracks_multiple_orders(self, sample_exchange_quotes): executor = DryRunExecutor(sample_exchange_quotes) - order1 = OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "dry_run", 0, - strike=67500, option_type="call") - order2 = OrderRequest("BTC-67500-P", "SELL", 1, "limit", 300.0, "dry_run", 1, - strike=67500, option_type="put") - r1 = executor.place_order(order1) - r2 = executor.place_order(order2) + 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 - executor = DryRunExecutor(sample_exchange_quotes) - report = execute_plan(plan, executor) - assert report.all_filled is True - assert len(report.results) == 1 + 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 - executor = DryRunExecutor(sample_exchange_quotes) - report = execute_plan(plan, executor) - assert report.all_filled is True - assert len(report.results) == 2 + 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): - """net_cost = sum(buy fills) - sum(sell fills).""" scored = _make_scored(multi_leg_strategy) plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) plan.dry_run = True - executor = DryRunExecutor(sample_exchange_quotes) - report = execute_plan(plan, executor) - buy_total = sum(r.fill_price * r.fill_quantity for r in report.results if r.action == "BUY") - sell_total = sum(r.fill_price * r.fill_quantity for r in report.results if r.action == "SELL") - assert report.net_cost == pytest.approx(buy_total - sell_total) + 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 - executor = DryRunExecutor(sample_exchange_quotes) - report = execute_plan(plan, executor) - assert "simulated" in report.summary - assert "Net cost" in report.summary + 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): - executor = get_executor("deribit", sample_exchange_quotes, dry_run=True) - assert isinstance(executor, DryRunExecutor) + assert isinstance(get_executor("deribit", sample_exchange_quotes, dry_run=True), DryRunExecutor) def test_dry_run_ignores_exchange(self, sample_exchange_quotes): - """dry_run=True always returns DryRunExecutor regardless of exchange.""" - executor = get_executor("aevo", sample_exchange_quotes, dry_run=True) - assert isinstance(executor, DryRunExecutor) + 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) @@ -361,649 +360,217 @@ def test_unknown_exchange(self, sample_exchange_quotes): with pytest.raises(ValueError, match="Unknown exchange"): get_executor("binance", sample_exchange_quotes, dry_run=False) - def test_auto_route_returns_factory(self, sample_exchange_quotes): - """exchange=None with dry_run=False returns a callable factory.""" - result = get_executor(None, sample_exchange_quotes, dry_run=False) - assert callable(result) - assert not isinstance(result, DryRunExecutor) - - def test_auto_route_dry_run_returns_executor(self, sample_exchange_quotes): - """exchange=None with dry_run=True still returns DryRunExecutor.""" - result = get_executor(None, sample_exchange_quotes, dry_run=True) - assert isinstance(result, DryRunExecutor) - - -class TestExecuteWithFactory: - def test_factory_routes_per_leg(self, sample_exchange_quotes): - """execute_plan with a callable factory creates executors per exchange.""" - plan = ExecutionPlan( - strategy_description="Test spread", 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, - ) - def _factory(exchange: str) -> DryRunExecutor: - return DryRunExecutor(sample_exchange_quotes) +# --- 7. Slippage (8 tests) --- - report = execute_plan(plan, _factory) - assert report.all_filled is True - assert len(report.results) == 2 - - def test_factory_auth_failure(self, sample_exchange_quotes): - """Factory executor that fails auth stops execution.""" - plan = ExecutionPlan( - strategy_description="Test", strategy_type="long_call", - exchange="auto", asset="BTC", expiry="", - orders=[ - OrderRequest("BTC-67500-C", "BUY", 1, "limit", 655.0, "bad_exchange", 0, - strike=67500, option_type="call"), - ], - dry_run=False, - ) - - class FailAuthExecutor(DryRunExecutor): - def authenticate(self): - return False - - def _factory(exchange: str): - return FailAuthExecutor(sample_exchange_quotes) +class TestSlippage: + def test_buy_worse(self): + assert _compute_slippage(100.0, 102.0, "BUY") == pytest.approx(2.0) - report = execute_plan(plan, _factory) - assert report.all_filled is False - assert "Authentication failed" in report.summary + 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) -class TestSlippage: - def test_compute_slippage_buy_worse(self): - """BUY at higher price = positive slippage.""" - slip = _compute_slippage(100.0, 102.0, "BUY") - assert slip == pytest.approx(2.0) - - def test_compute_slippage_buy_better(self): - """BUY at lower price = negative slippage (favorable).""" - slip = _compute_slippage(100.0, 98.0, "BUY") - assert slip == pytest.approx(-2.0) - - def test_compute_slippage_sell_worse(self): - """SELL at lower price = positive slippage.""" - slip = _compute_slippage(100.0, 98.0, "SELL") - assert slip == pytest.approx(2.0) - - def test_compute_slippage_zero_limit(self): - """Zero limit price returns 0 slippage.""" + def test_zero_limit(self): assert _compute_slippage(0.0, 100.0, "BUY") == 0.0 - def test_check_slippage_within(self): - from executor import OrderResult + 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_slippage_exceeded(self): - from executor import OrderResult + 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_plan_halts_on_slippage(self, sample_exchange_quotes): - """execute_plan with max_slippage_pct halts when exceeded.""" - plan = ExecutionPlan( - strategy_description="Test", 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, - ) - executor = DryRunExecutor(sample_exchange_quotes) - # Fill will be at 655.0 (aevo ask), limit is 600 -> slippage ~9.2% - report = execute_plan(plan, executor, max_slippage_pct=1.0) - assert report.all_filled is False - assert "Slippage exceeded" in report.summary + 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_plan_ok_slippage(self, sample_exchange_quotes): - """execute_plan passes when slippage is within limit.""" - plan = ExecutionPlan( - strategy_description="Test", 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, - ) - executor = DryRunExecutor(sample_exchange_quotes) - # Fill at 655.0, limit 655.0 -> 0% slippage - report = execute_plan(plan, executor, max_slippage_pct=5.0) - assert report.all_filled is True + 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 -class TestMaxLossBudget: - def test_validate_plan_within_budget(self): - plan = ExecutionPlan( - strategy_description="Test", strategy_type="long_call", - exchange="deribit", asset="BTC", expiry="", - orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "deribit", 0, - strike=67500, option_type="call")], - estimated_max_loss=500.0, - ) - valid, err = validate_plan(plan, max_loss_budget=1000.0) - assert valid is True - - def test_validate_plan_exceeds_budget(self): - plan = ExecutionPlan( - strategy_description="Test", strategy_type="long_call", - exchange="deribit", asset="BTC", expiry="", - orders=[OrderRequest("BTC-67500-C", "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 valid is False - assert "budget" in err.lower() - - def test_validate_plan_no_budget(self): - """No budget = no check.""" - plan = ExecutionPlan( - strategy_description="Test", strategy_type="long_call", - exchange="deribit", asset="BTC", expiry="", - orders=[OrderRequest("BTC-67500-C", "BUY", 1, "limit", 660.0, "deribit", 0, - strike=67500, option_type="call")], - estimated_max_loss=99999.0, - ) - valid, err = validate_plan(plan) - assert valid is True - - -class TestSizeMultiplier: - def test_size_doubles_quantity(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_default_one(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 plan.orders[0].quantity == 1 - - def test_size_scales_max_loss(self, sample_strategy, sample_exchange_quotes, btc_option_data): - scored = _make_scored(sample_strategy) - plan1 = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data, - size_multiplier=1) - plan2 = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data, - size_multiplier=5) - assert plan2.estimated_max_loss == pytest.approx(plan1.estimated_max_loss * 5) - +# --- 8. Execution Log (1 test — comprehensive) --- class TestExecutionLog: def test_save_and_load(self, sample_strategy, sample_exchange_quotes, btc_option_data, tmp_path): - import json scored = _make_scored(sample_strategy) plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) plan.dry_run = True - executor = DryRunExecutor(sample_exchange_quotes) - report = execute_plan(plan, executor) - + 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 - assert log["mode"] == "dry_run" - assert log["asset"] == "BTC" - assert log["all_filled"] is True - assert len(log["fills"]) == 1 - assert log["fills"][0]["status"] == "simulated" - assert "timestamp" in log - assert "slippage_total_pct" in log +# --- 9. Retry (5 tests) --- class TestRetryable: - def test_timeout_is_retryable(self): + def test_timeout(self): assert _is_retryable(requests.Timeout()) is True - def test_connection_error_is_retryable(self): + def test_connection_error(self): assert _is_retryable(requests.ConnectionError()) is True - def test_value_error_not_retryable(self): + def test_value_error_not(self): assert _is_retryable(ValueError("bad")) is False - def test_http_429_retryable(self): - from unittest.mock import Mock - resp = Mock() - resp.status_code = 429 - err = requests.HTTPError(response=resp) - assert _is_retryable(err) is True - - def test_http_400_not_retryable(self): - from unittest.mock import Mock - resp = Mock() - resp.status_code = 400 - err = requests.HTTPError(response=resp) - assert _is_retryable(err) is False - + def test_http_429(self): + resp = type("R", (), {"status_code": 429})() + assert _is_retryable(requests.HTTPError(response=resp)) is True -class TestTimestampsAndLatency: - """Verify that OrderResult and ExecutionReport include timing data.""" + def test_http_400_not(self): + resp = type("R", (), {"status_code": 400})() + assert _is_retryable(requests.HTTPError(response=resp)) is False - def test_dry_run_result_has_timestamp(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") - result = executor.place_order(order) - assert result.timestamp != "" - assert "T" in result.timestamp # ISO 8601 format - - def test_dry_run_result_has_latency(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") - result = executor.place_order(order) - assert result.latency_ms >= 0 - - def test_error_result_has_timestamp(self, sample_exchange_quotes): - executor = DryRunExecutor(sample_exchange_quotes) - order = OrderRequest("INVALID", "BUY", 1, "limit", 100.0, "dry_run", 0) - result = executor.place_order(order) - assert result.timestamp != "" - - def test_report_has_started_finished(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 - executor = DryRunExecutor(sample_exchange_quotes) - report = execute_plan(plan, executor) - assert report.started_at != "" - assert report.finished_at != "" - assert "T" in report.started_at - assert "T" in report.finished_at - - def test_now_iso_format(self): - ts = _now_iso() - assert "T" in ts - assert "+" in ts or "Z" in ts # timezone info +# --- 10. Monitoring + Cancel (7 tests) --- -class TestOrderMonitoring: - """Test _monitor_order polling and timeout behavior.""" - +class TestMonitoringAndCancel: def test_immediate_fill(self, sample_exchange_quotes): - """DryRunExecutor returns 'simulated' = terminal. Monitor returns OrderResult.""" 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) + 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" - assert result.order_id == placed.order_id def test_timeout_triggers_cancel(self): - """Executor that always returns 'open' should timeout and cancel.""" - class StuckExecutor(BaseExecutor): + class StuckExec(BaseExecutor): def authenticate(self): return True def place_order(self, order): pass - def get_order_status(self, order_id): return _status_result(order_id, "open") - def cancel_order(self, order_id): return True - - executor = StuckExecutor() - result = _monitor_order(executor, "stuck-123", timeout_seconds=0.1, poll_interval=0.05) - assert result.status == "timeout" + 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): - """Executor that fills after a few polls.""" - call_count = {"n": 0} - - class DelayedExecutor(BaseExecutor): + n = {"c": 0} + class DelayExec(BaseExecutor): def authenticate(self): return True def place_order(self, order): pass - def get_order_status(self, order_id): - call_count["n"] += 1 - if call_count["n"] >= 3: - return OrderResult(order_id=order_id, status="filled", - fill_price=100.0, fill_quantity=1, - instrument="X", action="BUY", exchange="test") - return _status_result(order_id, "open") - def cancel_order(self, order_id): return True - - executor = DelayedExecutor() - result = _monitor_order(executor, "delay-123", timeout_seconds=5.0, poll_interval=0.01) - assert result.status == "filled" - assert call_count["n"] >= 3 - - def test_rejected_is_terminal(self): - """Executor returning 'rejected' should stop immediately.""" - class RejectExecutor(BaseExecutor): + 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, order_id): return _status_result(order_id, "rejected") - def cancel_order(self, order_id): return True - - executor = RejectExecutor() - result = _monitor_order(executor, "rej-123", timeout_seconds=5.0) - assert result.status == "rejected" - - def test_monitor_returns_fill_data(self): - """Monitor should return OrderResult with fill data when available.""" - class FillExecutor(BaseExecutor): + 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, order_id): - return OrderResult(order_id=order_id, status="filled", - fill_price=650.0, fill_quantity=1, - instrument="BTC-67500-C", action="BUY", exchange="deribit") - def cancel_order(self, order_id): return True - - result = _monitor_order(FillExecutor(), "fill-1", timeout_seconds=5.0) - assert result.fill_price == 650.0 - assert result.fill_quantity == 1 - + 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"] -class TestAutoCancel: - """Test _cancel_filled_orders cancellation logic.""" - - def test_cancels_filled_orders(self): - results = [ - OrderResult("id-1", "filled", 100.0, 1, "X", "BUY", "deribit"), - OrderResult("id-2", "filled", 200.0, 1, "Y", "SELL", "aevo"), - ] - cancel_log = [] - - class TrackingExecutor(BaseExecutor): - def authenticate(self): return True - def place_order(self, order): pass - def get_order_status(self, order_id): return _status_result(order_id, "filled") - def cancel_order(self, order_id): - cancel_log.append(order_id) - return True - - tracker = TrackingExecutor() - cancelled = _cancel_filled_orders(results, lambda ex: tracker) - assert cancelled == ["id-1", "id-2"] - assert cancel_log == ["id-1", "id-2"] - - def test_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"), - ] - # DryRunExecutor cancel returns False for unknown IDs now (stateful) + 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([]) - # Manually add the order so cancel works executor._orders["id-2"] = results[1] - cancelled = _cancel_filled_orders(results, lambda ex: executor) - assert cancelled == ["id-2"] - - def test_empty_results(self): - cancelled = _cancel_filled_orders([], lambda ex: DryRunExecutor([])) - assert cancelled == [] + assert _cancel_filled_orders(results, lambda ex: executor) == ["id-2"] def test_cancel_failure_skips(self): - """If cancel_order returns False, order ID not in cancelled list.""" - results = [ - OrderResult("id-1", "filled", 100.0, 1, "X", "BUY", "deribit"), - ] - - class FailCancelExecutor(BaseExecutor): + 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, order_id): return _status_result(order_id, "filled") - def cancel_order(self, order_id): return False + 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()) == [] - cancelled = _cancel_filled_orders(results, lambda ex: FailCancelExecutor()) - assert cancelled == [] +# --- 11. Execution Savings (3 tests) --- class TestExecutionSavings: - """Test compute_execution_savings comparison logic.""" - def test_savings_when_cheaper(self, btc_option_data): - """When execution price < Synth price, savings should be positive.""" - plan = ExecutionPlan( - strategy_description="Test", 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")], - ) - savings = compute_execution_savings(plan, btc_option_data) - # Synth price for 67500 call = 638.43, exec = 600 → savings = 38.43 - assert savings["savings_usd"] > 0 - assert savings["synth_theoretical_cost"] == pytest.approx(638.43) - assert savings["execution_cost"] == pytest.approx(600.0) + 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): - """When execution price = Synth price, savings = 0.""" - plan = ExecutionPlan( - strategy_description="Test", 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")], - ) - savings = compute_execution_savings(plan, btc_option_data) - assert savings["savings_usd"] == pytest.approx(0.0) + 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): - """Multi-leg spread: savings on net cost.""" - plan = ExecutionPlan( - strategy_description="Test spread", strategy_type="call_debit_spread", - exchange="auto", asset="BTC", expiry="", + 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"), - ], - ) - savings = compute_execution_savings(plan, btc_option_data) - # Synth: 987.04 - 373.27 = 613.77, Exec: 950 - 390 = 560 → savings ~53.77 - assert savings["savings_usd"] > 0 - assert "savings_pct" in savings - - -class TestExecutePlanWithTimeout: - """Test execute_plan timeout_seconds parameter.""" - - def test_timeout_not_used_for_simulated(self, sample_exchange_quotes): - """Dry-run fills are 'simulated', never 'open', so timeout monitoring doesn't trigger.""" - plan = ExecutionPlan( - strategy_description="Test", 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, - ) - executor = DryRunExecutor(sample_exchange_quotes) - report = execute_plan(plan, executor, timeout_seconds=5.0) - assert report.all_filled is True - # Status should still be simulated, not changed by monitoring - assert report.results[0].status == "simulated" + 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 - def test_timeout_triggers_for_open_orders(self): - """Executor that returns 'open' status should trigger monitoring → timeout.""" - class OpenExecutor(BaseExecutor): - def authenticate(self): return True - def place_order(self, order): - return OrderResult( - order_id="open-123", status="open", fill_price=0.0, - fill_quantity=0, instrument=order.instrument, - action=order.action, exchange="test", - ) - def get_order_status(self, order_id): return _status_result(order_id, "open") - def cancel_order(self, order_id): return True - - plan = ExecutionPlan( - strategy_description="Test", 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) - # Should timeout and fail - assert report.all_filled is False - assert report.results[0].status == "timeout" - - def test_plan_timeout_default(self): - """ExecutionPlan has default timeout_seconds=30.""" - plan = ExecutionPlan( - strategy_description="Test", strategy_type="long_call", - exchange="deribit", asset="BTC", expiry="", - ) - assert plan.timeout_seconds == 30.0 +# --- 12. Aevo Signing (3 tests) --- class TestAevoSigning: - """Test Aevo HMAC-SHA256 4-part signing.""" - - def test_sign_uses_four_parts(self): - """Signature message = timestamp + method + path + body.""" - import hashlib, hmac as hmac_mod + def test_four_part_signature(self): executor = AevoExecutor("test-key", "test-secret", testnet=True) sig = executor._sign("12345", "POST", "/orders", '{"side":"buy"}') - expected_msg = '12345POST/orders{"side":"buy"}' - expected_sig = hmac_mod.new( - b"test-secret", expected_msg.encode(), hashlib.sha256, - ).hexdigest() - assert sig == expected_sig - - def test_headers_include_all_fields(self): - executor = AevoExecutor("my-key", "my-secret", testnet=True) - headers = executor._headers("POST", "/orders", '{"side":"buy"}') - assert headers["AEVO-KEY"] == "my-key" - assert "AEVO-TIMESTAMP" in headers - assert "AEVO-SIGNATURE" in headers - assert headers["Content-Type"] == "application/json" - - def test_sign_empty_body(self): - """GET request with empty body still produces valid signature.""" - executor = AevoExecutor("k", "s", testnet=True) - sig = executor._sign("999", "GET", "/orders/123", "") - assert len(sig) == 64 # SHA-256 hex digest - - -class TestExecutionLogEnhanced: - """Verify execution log includes new fields.""" - - def test_log_contains_timestamps_and_latency(self, sample_strategy, sample_exchange_quotes, - btc_option_data, tmp_path): - import json - scored = _make_scored(sample_strategy) - plan = build_execution_plan(scored, "BTC", "deribit", sample_exchange_quotes, btc_option_data) - plan.dry_run = True - executor = DryRunExecutor(sample_exchange_quotes) - report = execute_plan(plan, executor) - - log_path = str(tmp_path / "enhanced_log.json") - save_execution_log(report, log_path) - - with open(log_path) as f: - log = json.load(f) - - assert log["started_at"] != "" - assert log["finished_at"] != "" - assert isinstance(log["cancelled_orders"], list) - assert log["fills"][0]["timestamp"] != "" - assert log["fills"][0]["latency_ms"] >= 0 + expected = hmac_mod.new(b"test-secret", b'12345POST/orders{"side":"buy"}', hashlib.sha256).hexdigest() + assert sig == expected + def test_headers(self): + h = AevoExecutor("my-key", "my-secret", testnet=True)._headers("POST", "/orders", '{}') + assert h["AEVO-KEY"] == "my-key" and "AEVO-TIMESTAMP" in h and "AEVO-SIGNATURE" in h -class TestPartialFillAutoCancel: - """Test that execute_plan auto-cancels on partial failure.""" + def test_empty_body(self): + assert len(AevoExecutor("k", "s", testnet=True)._sign("9", "GET", "/x", "")) == 64 - def test_second_leg_failure_cancels_first(self, sample_exchange_quotes): - """When second order fails, first filled order gets cancelled.""" - call_count = {"n": 0} - - class PartialExecutor(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, order_id): return _status_result(order_id, "filled") - def cancel_order(self, order_id): return True - - plan = ExecutionPlan( - strategy_description="Test spread", 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, PartialExecutor()) - assert report.all_filled is False - assert "Partial fill" in report.summary - assert len(report.cancelled_orders) > 0 +# --- 13. Deribit Price Conversion (4 tests) --- class TestDeribitPriceConversion: - """Test Deribit USD→BTC conversion, tick alignment, and order book snapping.""" - - def test_align_tick_basic(self): - from executor import DeribitExecutor - # 0.00973 → nearest 0.0005 = 0.0095 (rounds down) + def test_align_tick(self): assert DeribitExecutor._align_tick(0.00973, 0.0005) == 0.0095 - assert DeribitExecutor._align_tick(0.00950, 0.0005) == 0.0095 - # 0.00975 → 0.01 (rounds to nearest) assert DeribitExecutor._align_tick(0.00975, 0.0005) == 0.01 - - def test_align_tick_exact(self): - from executor import DeribitExecutor assert DeribitExecutor._align_tick(0.0100, 0.0005) == 0.01 - def test_align_tick_zero_tick_size(self): - from executor import DeribitExecutor - assert DeribitExecutor._align_tick(0.00973, 0) == 0.00973 - - def test_usd_to_btc_conversion(self): - """USD price / index price → BTC price, aligned to tick.""" - from executor import DeribitExecutor + def test_usd_to_btc(self): executor = DeribitExecutor("id", "secret", testnet=True) executor._index_cache["BTC"] = 67000.0 - btc_price = executor._usd_to_btc(670.0, "BTC") - # 670 / 67000 = 0.01 exactly - assert btc_price == 0.01 + assert executor._usd_to_btc(670.0, "BTC") == 0.01 - def test_usd_to_btc_fallback_no_index(self): - """When index is 0/unavailable, returns USD price as-is.""" - from executor import DeribitExecutor + def test_usd_to_btc_fallback(self): executor = DeribitExecutor("id", "secret", testnet=True) - # No index cached, _get_index_price will fail (no network) executor._index_cache["BTC"] = 0.0 - btc_price = executor._usd_to_btc(670.0, "BTC") - assert btc_price == 670.0 + assert executor._usd_to_btc(670.0, "BTC") == 670.0 - def test_index_cache_reuse(self): - """Index price is cached after first call.""" - from executor import DeribitExecutor + def test_index_cache(self): executor = DeribitExecutor("id", "secret", testnet=True) executor._index_cache["ETH"] = 3500.0 - # Second call should return cached value assert executor._get_index_price("ETH") == 3500.0 -class TestScreenParseNone: - """Test --screen none/0 support.""" +# --- 14. CLI Helpers (5 tests) --- +class TestCLI: def test_screen_none(self): - import sys, os - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from main import _parse_screen_arg assert _parse_screen_arg("none") == set() @@ -1015,51 +582,10 @@ def test_screen_all(self): from main import _parse_screen_arg assert _parse_screen_arg("all") == {1, 2, 3, 4} - def test_screen_subset(self): - from main import _parse_screen_arg - assert _parse_screen_arg("1,3") == {1, 3} - - -class TestRefuseExecution: - """Test _refuse_execution guardrail function.""" - - def test_refuses_live_with_no_trade(self): - from main import _refuse_execution - result = _refuse_execution(True, "Countermove detected", False) - assert result is not None - assert "Guardrail" in result - - def test_allows_with_force(self): + def test_refuse_execution_blocks(self): from main import _refuse_execution - result = _refuse_execution(True, "Countermove detected", True) - assert result is None + assert _refuse_execution(True, "Countermove", False) is not None - def test_allows_dry_run(self): + def test_refuse_execution_allows_force(self): from main import _refuse_execution - result = _refuse_execution(False, "Countermove detected", False) - assert result is None - - def test_allows_no_reason(self): - from main import _refuse_execution - result = _refuse_execution(True, None, False) - assert result is None - - -class TestGetOrderStatusReturnsOrderResult: - """Verify get_order_status returns OrderResult across all executors.""" - - def test_dry_run_returns_order_result(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" - assert result.fill_price > 0 - - def test_dry_run_not_found_returns_order_result(self, sample_exchange_quotes): - executor = DryRunExecutor(sample_exchange_quotes) - result = executor.get_order_status("nonexistent") - assert isinstance(result, OrderResult) - assert result.status == "not_found" + assert _refuse_execution(True, "Countermove", True) is None From 19fbd5206c38bd5363d00c9aae8f8dd9f14c0303 Mon Sep 17 00:00:00 2001 From: eureka928 Date: Sat, 14 Mar 2026 03:45:50 +0100 Subject: [PATCH 6/6] feat: add EIP-712 L2 signing for Aevo order placement AevoExecutor now signs orders with EIP-712 typed data using eth-account, matching Aevo's OP Stack L2 settlement architecture. Adds instrument ID resolution, 6-decimal fixed-point pricing, and proper order payload format. --- tools/options-gps/executor.py | 153 ++++++++++++++++++++--- tools/options-gps/requirements.txt | 1 + tools/options-gps/tests/test_executor.py | 40 +++++- 3 files changed, 169 insertions(+), 25 deletions(-) diff --git a/tools/options-gps/executor.py b/tools/options-gps/executor.py index ada090c..8f2902e 100644 --- a/tools/options-gps/executor.py +++ b/tools/options-gps/executor.py @@ -1,6 +1,6 @@ """Autonomous execution engine for Options GPS. Consumes pipeline.py data classes and exchange.py pricing functions. -Supports Deribit (JSON-RPC 2.0), Aevo (REST + HMAC-SHA256), and dry-run simulation. +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.""" @@ -9,6 +9,7 @@ import hmac import json import os +import random import time import uuid from abc import ABC, abstractmethod @@ -17,6 +18,12 @@ 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 @@ -407,20 +414,53 @@ def cancel_order(self, order_id: str) -> bool: class AevoExecutor(BaseExecutor): - """Executes orders on Aevo via REST API with HMAC-SHA256 signing. - Signs timestamp + HTTP method + path + body per Aevo spec. + """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).""" - def __init__(self, api_key: str, api_secret: str, testnet: bool = False): + # 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(self, timestamp: str, method: str, path: str, body: str) -> str: + 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, @@ -431,26 +471,93 @@ def _headers(self, method: str = "GET", path: str = "/", body: str = "") -> dict return { "AEVO-KEY": self.api_key, "AEVO-TIMESTAMP": ts, - "AEVO-SIGNATURE": self._sign(ts, method, path, body), + "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) + 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() - payload = { - "instrument": order.instrument, - "side": order.action.lower(), - "quantity": order.quantity, - "order_type": order.order_type, - } - if order.order_type == "limit": - payload["price"] = order.price - body = json.dumps(payload) 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", @@ -500,8 +607,8 @@ def get_order_status(self, order_id: str) -> OrderResult: status=data.get("status", "unknown"), fill_price=float(data.get("avg_price", 0)), fill_quantity=int(data.get("filled", 0)), - instrument=data.get("instrument", ""), - action=data.get("side", "").upper(), + instrument=data.get("instrument_name", ""), + action="BUY" if data.get("is_buy") else "SELL", exchange="aevo", ) except Exception: @@ -917,13 +1024,21 @@ def _executor_factory(ex: str): 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, testnet) + 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/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 index ca22864..4804f57 100644 --- a/tools/options-gps/tests/test_executor.py +++ b/tools/options-gps/tests/test_executor.py @@ -353,6 +353,8 @@ def test_missing_deribit_creds(self, sample_exchange_quotes, monkeypatch): 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) @@ -526,21 +528,47 @@ def test_multi_leg_savings(self, btc_option_data): assert compute_execution_savings(plan, btc_option_data)["savings_usd"] > 0 -# --- 12. Aevo Signing (3 tests) --- +# --- 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_four_part_signature(self): - executor = AevoExecutor("test-key", "test-secret", testnet=True) - sig = executor._sign("12345", "POST", "/orders", '{"side":"buy"}') + 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 = AevoExecutor("my-key", "my-secret", testnet=True)._headers("POST", "/orders", '{}') + 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(AevoExecutor("k", "s", testnet=True)._sign("9", "GET", "/x", "")) == 64 + 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) ---