July 7, 2026
Option arbitrage bot
Crypto options markets are fragmented. Deribit, Derive, and Aevo all list the same instruments but their order books are not mechanically linked. A BTC-20251025-70000-C can trade at 0.025 BTC on Deribit and 0.028 BTC on Derive at the same moment. Net of fees, that spread is free money if you are fast enough.
This bot detects those mispricings in real time and executes both legs simultaneously: buy the cheap side, sell the expensive side, before the gap closes.
The strategy is non-directional. Both legs hedge each other. The real risks are elsewhere: execution latency, liquidity that disappears between detection and placement, and the mechanics of on-chain settlement on Derive.

The formula
gross_spread% = (bid_A - ask_B) / ask_B × 100
fee% = (taker_rate_A + taker_rate_B) × 100
net_spread% = gross_spread% - fee%
APR = (net_spread% / days_to_expiry) × 365APR normalizes opportunities to an annualized basis, comparable to a bond yield. It is the main quality filter. A 0.3% spread on an option expiring tomorrow is much more interesting than the same spread on one expiring in three months.
Architecture
The screener and executor are separated for a reason. The screener detects. The executor re-validates on fresh order book data before placing anything. The screener ticks every 500ms, the executor every 200ms. Everything runs as async Python with asyncio.
The executor runs in its own Docker container, isolated from the screener. A crash in the alerter or the metadata refresh cannot interrupt a live execution. Restarting the executor does not drop the active WebSocket connections.
Technical challenges
Price normalization across exchanges
Deribit quotes inverse contracts in BTC, not USD. A bid_price: 0.025 means 0.025 BTC, not $0.025. At $70,000 per BTC, that is $1,750. Derive quotes everything in USD.
If you compare those prices directly, the screener detects a phantom spread that moves with the BTC spot price. The fix: the Deribit adapter multiplies every price by underlying_price on receipt, in both REST and WebSocket paths. Everything that leaves the adapter is in USD. The comparator never sees BTC-denominated prices.
Two-lane rate limiter
Each exchange has a request budget per second. Early on, metadata refreshes and book snapshots would consume all available slots in bursts, and the executor had to wait its turn. 200-300ms of waiting is enough to miss an opportunity.
The solution is two lanes in the HTTP client. The total quota is split into normal (screener, metadata) and priority (executor). The executor always passes priority=True. Even under maximum screener load, it keeps 5 reserved slots per second that nothing else can consume.
The executor state machine
The executor has four kill-switches checked before every execution: a EXECUTOR_DISABLED flag file, a cap on open positions, a daily loss limit, and a per-trade notional cap. If any trips, the opportunity is rejected with the reason logged.
After kill-switches pass, the executor refetches both L2 books via REST (500ms timeout), recalculates the spread on real current data, and finds the optimal trade size by walking the book until APR drops below the minimum threshold. Only then does it place both orders.
Partial fill handling
The most serious operational risk: one leg fills, the other gets rejected because liquidity disappeared between the L2 refetch and the placement. That leaves an unhedged option position.
If one leg fills and the other does not, the executor immediately places a market-out order on the orphaned leg: sell it at mid ± slippage via IOC. If that succeeds, the trade ends as HEDGED with a controlled loss. If it fails too, the trade goes STUCK: an alert fires on Telegram, the position counts against the open positions cap, and manual intervention is required.
Every status transition is persisted to the database before the next await. If the container crashes mid-execution, the DB state is consistent.
Instrument name normalization
Deribit names options BTC-25OCT25-30000-C. Derive uses BTC-20251025-30000-C. Without normalization, the comparator cannot match instruments across exchanges.
exchanges/naming.py converts every exchange's native name into a canonical format: {UNDERLYING}-{YYYYMMDD}-{STRIKE}-{C|P}. Matching is purely on this normalized name. If two instruments share it, they are the same option regardless of how each exchange labels it.
Frontend

7 monitoring pages built with Vite, React 19, TypeScript, TanStack Query, and Tailwind CSS. All data comes through the FastAPI REST layer: nothing touches Postgres directly from the browser.
| Page | What it shows |
|---|---|
| Opportunities | Main table. Instrument, DTE, route, buy capital, sell premium, fees, net profit, spread %, APR %. Sortable columns, horizontal scroll, sticky instrument column. |
| Book | Live order book per exchange. |
| Trades | Trade history with mode and status filters. |
| History | All detected opportunities over time. |
| Positions | Per-exchange balance, open positions, WebSocket connection status. |
| Executor | Kill-switch states, recent alert log, Kill/Resume buttons with modal confirmation. |
| Funding | Funding rate data. |
The frontend uses refetchInterval: 5000 for baseline polling and a useSSE hook for real-time push on critical events. The backend publishes 10 event types on an asyncio fan-out bus: opportunity_detected, trade_filled, trade_stuck, kill_switch_tripped, and others. Each SSE client gets its own queue.
Stack
Python, asyncio, FastAPI, SQLModel, PostgreSQL, Alembic, React, TypeScript, TanStack Query, Tailwind CSS, Docker Compose. Caddy handles TLS termination and reverse proxy in front of nginx (frontend) and FastAPI (API).
The paper trading mode is identical to live: the only difference is which exchange adapter is injected. MockExchange simulates fills via a slippage model that walks the L2 book, applies Gaussian noise, and respects limit prices. The executor, screener, and comparator run exactly the same code either way.