Imported from facoleur/funding-rate-arbitrage (
AGENTS.md). Install upstream withnpx skills add facoleur/funding-rate-arbitrage. Copyright stays with the author.
AGENTS.md — Option Arbitrage
Cross-exchange crypto options arbitrage system. Detect + execute spreads where the highest bid on exchange A exceeds the lowest ask on exchange B for the same option instrument, net of taker fees. Nothing else.
Perpetual-funding arbitrage code was previously in this repo and has been deleted. Do not reintroduce it. The prior TypeScript prototype has also been deleted — the Python backend/ is the sole source of truth.
Repo layout
option_arbitrage/
├── AGENTS.md # this file
├── CLAUDE.md # Claude Code session pointer
├── backend/ # Python (FastAPI + uv + asyncio)
│ ├── AGENTS.md # backend internals
│ ├── src/option_arb/
│ ├── tests/
│ ├── pyproject.toml
│ ├── Dockerfile # prod image (single-stage, source baked in)
│ └── Dockerfile.dev # dev image (deps only, source mounted via volume)
├── frontend/ # Vite + React + TanStack Query (built)
│ ├── REQUIREMENTS.md # pages, stack, API contract
│ ├── src/pages/ # Book, Executor, Funding, History, Opportunities, Positions, Trades
│ ├── Dockerfile # prod: build → nginx
│ └── Dockerfile.dev # dev: node image, source mounted
├── docs/
│ └── deribit-vs-derive-options.md # pricing traps, settlement, auth latency per exchange
├── data/ # (formerly SQLite; now unused for runtime — pg volume owns state)
├── docker/ # VPS deployment (Caddyfile.example, README)
├── docker-compose.yml # prod: postgres + migrate + api + workers + executor + frontend
├── docker-compose.dev.yml # dev: same stack, source mounted, watchfiles hot-reload
├── Makefile # canonical local entry-points
├── config.yaml # runtime knobs (thresholds, limits, kill-switches)
└── .env.example
Current state
- Backend Python (
backend/) — all phases 1-12 landed.- REST + WS adapters for Deribit (inverse + linear) / Derive (public + private) / Aevo (public aggregate book-ticker + index channels; private auth deferred).
- Screener, executor (with 4 kill-switches + market-out on single-leg fill), rebalancer (monitoring only), alerter (Telegram), perp hedger (BTC-PERPETUAL short on Deribit inverse to neutralize BTC collateral), MockExchange with SlippageModel, backtest + record CLIs.
- 74 tests passing.
- Frontend (
frontend/) — built. Vite + React + TanStack Query + React Router, 7 pages. Served via nginx in thefrontendDocker container. Spec infrontend/REQUIREMENTS.md. - Trigger.dev + Telegram TS legacy — deleted. Telegram is re-implemented in
backend/src/option_arb/services/alerter.py.
Target architecture
Stack: FastAPI + uv + Pydantic + asyncio + httpx + websockets + SQLModel + Alembic + Postgres.
Data plane
├── screener WS tickers → in-memory book_cache → detect → write opportunities
├── executor picks PENDING opps → REST L2 refresh → 2 IOC limits → market-out on fail
├── rebalancer monitors positions/balances/expiries — alerts only, no auto action
├── perp_hedger maintains BTC-PERPETUAL short to hedge Deribit inverse BTC collateral
└── alerter Telegram (+ future channels) via asyncio event bus
Storage
└── Postgres 16 shared by all backend services; frontend reads via REST only.
SQLite is retained ONLY for pytest (fast, isolated per test).
API surface
└── FastAPI REST for lists/detail + admin kill/resume + perp-hedge pause/resume
SSE /api/stream for live push events
3 backend containers (option C — executor isolated): api, workers, executor + a postgres service and a one-shot migrate. See docker-compose.yml.
Data fetching strategy
- REST bootstrap (every 6h) — instrument metadata.
- WebSocket tickers (permanent) — one connection per exchange (Deribit inverse, Deribit linear, Derive, Aevo). Aevo uses aggregate
book-ticker:{ASSET}:OPTIONplusindex:{ASSET}channels. - Screener — reads cache in-memory every 500ms, groups by normalized name, writes
opportunitiesPENDING. - Executor — before placing, does a fresh REST L2 fetch on both venues (500ms timeout), walks the book, re-verifies APR net of slippage, then places IOC limits.
Authentication (per exchange)
Every adapter takes an optional Authenticator (see backend/src/option_arb/exchanges/auth.py). Public paths ignore it; private paths require it. Without one, adapters return REJECTED / empty instead of hitting the network.
| Exchange | Model | Class | Status |
|---|---|---|---|
| Deribit | OAuth 2.0 client_credentials |
DeribitOAuth |
✅ implemented — token fetch + refresh (~1h TTL) |
| Derive (Lyra V2) | Session-key signing (custom digest: keccak(0x1901 || DOMAIN_SEPARATOR || action_hash)) |
DeriveAuth (wraps official derive_action_signing lib) |
✅ implemented — signs trades + X-LYRA* REST headers |
| Aevo | Aggregate public WebSocket | NoAuth |
Public market data implemented; private auth deferred. |
All exchanges are currently configured as mainnet in config.yaml. Flip network: testnet + swap the rest_base_url / ws_url to use testnet.
Deribit (see AGENTS.md step-by-step or .env.example):
- UI → Account → API → Add new key with scopes
trade:read_write+wallet:read_write; IP allowlist recommended. .env:DERIBIT_CLIENT_ID,DERIBIT_CLIENT_SECRET.- Token TTL ≈ 3600s, refreshed automatically 60s before expiry.
Derive:
- Deposit USDC on app.derive.xyz → creates SCW + subaccount.
- UI → Settings → API Keys → Create Session Key (admin, 30-day expiry).
.env:DERIVE_WALLET_ADDRESS(SCW, not your EOA),DERIVE_SUBACCOUNT_ID,DERIVE_SESSION_PRIVATE_KEY.- Protocol constants (DOMAIN_SEPARATOR, ACTION_TYPEHASH, TRADE_MODULE) are baked into
exchanges/derive_constants.pyfor both mainnet + testnet. - Signing is done via the official
derive-action-signingpackage (already a dep).DeriveAuth.sign_trade_action(...)produces the payload that merges into/private/order.
Storage rules (both):
- Local dev:
.envat repo root,chmod 600 .env. Not committed. - Prod: proper secrets manager (Vault / Doppler / AWS Secrets Manager). Never bake into an image.
- Rotate Derive session keys every 30 days.
Conventions
- Normalized instrument name:
{UNDERLYING}-{YYYYMMDD}-{STRIKE}-{C|P}(e.g.BTC-20251025-30000-C). Every adapter MUST emit this. - Prices in quote currency (USD). Deribit returns bid/ask in underlying units — the adapter multiplies by
underlying_priceto convert. - Fees =
buy_premium × buy_taker_fee_rate + sell_premium × sell_taker_fee_rate. - Capital required = standalone estimated short margin + buy premium. Sell premium does not offset capital.
- Net return =
net_profit_usd / capital_required_usd × 100; APR =net_return_pct × 365 / days_to_expiry. - Liquidity floor:
bid_price × bid_qty >= min_leg_premium_liquidity_usd(default $50). - Decimal, not float for prices in the comparator + executor.
- Modes: every opportunity / trade tagged
mode ∈ {live, paper, backtest}.
Runtime config
Central YAML at config.yaml (mounted read-only into every container). Env .env holds only secrets + DATABASE_URL + CONFIG_PATH.
Key knobs:
thresholds.min_apr_pct,min_net_return_pct,min_net_profit_usd,min_buy_premium_usd,min_leg_premium_liquidity_usdexecutor.mode(paper|live),ioc_slippage_limit_pctlimits.max_buy_premium_per_trade_usd,max_positions_open,max_daily_loss_usd,kill_switch_fileperp_hedge.enabled,rebalance_threshold_usd,poll_interval_sec,kill_switch_fileexchanges.*.rest_rate_limit_per_sec,ws_max_subscriptions
Executor kill-switches (4, all active)
- Max buy premium per trade — refuses if worst-IOC buy premium exceeds the cap.
- Max open positions — refuses when active-trade count is at cap.
- Max daily loss — refuses if realised PnL since midnight UTC is below
-cap. - Manual — file
data/EXECUTOR_DISABLEDORPOST /api/executor/kill. Checked every loop iteration.
Perp hedger kill-switch
- File
data/PERP_HEDGE_DISABLEDORPOST /api/perp-hedge/pause→ pauses rebalancing (orders dry-run). POST /api/perp-hedge/resume/DELETE data/PERP_HEDGE_DISABLED→ resumes.- In
executor.mode: paper, hedger always runs dry (logs delta but never places real orders).
Testing model
Mandatory paper mode before live. MockExchange mirrors real books but simulates fills via SlippageModel (walks the book, gaussian noise, random rejection, latency, respects limit price). Same screener + executor code runs in both modes.
Backtest CLI replays recorded book_snapshots through the pipeline, tagged mode=backtest.
Test DB: pytest uses SQLite for speed and isolation (fresh .db file per test via test_db fixture in backend/tests/conftest.py). Production runs on Postgres. Model code is DB-agnostic (SQLModel + SQLAlchemy).
Coverage: 74 tests across comparator, HTTP rate limit / retry / circuit, screener, executor (happy path + all 4 kill-switches + stale book + apr dropped + empty book + STUCK on failed market-out), mock exchange, alerter (persistence + threshold + level filter), rebalancer (low balance + expiring + unhealthy), auth (NoAuth + Deribit OAuth token cache + EIP-712 signer), WS manager (subscribe payload per exchange + reconnect), adapters (WS ticker parsing per exchange), DeriveAuth (constants + LYRA headers + end-to-end sign+validate).
Local dev entry-points (Makefile)
make up # docker compose up -d (full stack: postgres + api + workers + executor + frontend)
make down
make dev # hot-reload stack: source mounted, watchfiles on backend, vite on frontend
make dev-down # stop dev stack
make live # foreground, live mode (typed confirmation)
make logs svc=api # tail one service
# Local dev without full docker:
make db # start only postgres
make dev-api # api on host (uvicorn hot reload)
make dev-worker
make dev-executor
# Tests + lint:
make test # pytest (uses SQLite per-test)
make lint # ruff check
make format # ruff format
make typecheck # mypy
# DB:
make migrate # alembic upgrade head
make migrate-new msg="…"
make db-shell # psql into the postgres container
# Executor safety:
make kill / make resume
# Backtest + record:
make record ex=derive dur=1h
make backtest file=recordings/derive-*.jsonl
For AI agents working here
- Read
backend/AGENTS.mdfor backend internals before editing. - Read
frontend/REQUIREMENTS.mdfor frontend pages, stack, and API contract. - Read
docs/deribit-vs-derive-options.mdbefore touching exchange adapters, executor, or pricing logic — contains per-exchange pricing traps, settlement differences, and auth latency risks. - Do not reintroduce funding-rate code.
- Frontend never touches Postgres directly — read-only via REST.
- When adding a new exchange, implement
AbstractExchange(backend/src/option_arb/exchanges/base.py): rate-limited HTTP via the shared wrapper, WS subscribe,normalized_nameoutput, optionalAuthenticatorfor private paths. - Any code touching order placement must have a
MockExchangepath and unit tests covering the 4 kill-switches. Never wire the live executor without paper validation. - The executor is the highest-blast-radius component. State transitions persist to
trades+ordersbefore the next await; kill-switches are honoured every loop. - Reference plan:
~/.claude/plans/rippling-gathering-fountain.md. - VPS deployment: see the "VPS deployment" section of
CLAUDE.md(authoritative —docker/README.mdis legacy). Compose hardening lives there: log rotation,mem_limit,autoheal, heartbeat healthchecks,scripts/vps-monitor.sh(Telegram system report every 2h),scripts/vps-setup-swap.sh.
Open decisions
- Aevo private trading — signing pattern deferred; public market data uses aggregate WebSocket channels.
- Where to source recorded order-book data for long-window backtests.
- Slippage-model coefficients — empirical calibration once we have real fills.
- Secrets manager choice for prod session keys (Vault vs Doppler vs env-only).