Skip to content
This repository was archived by the owner on Aug 5, 2026. It is now read-only.

Repository files navigation

Far-OTM vertical spread arbitrage

Important

This repository is archived. The bot no longer runs and the code is not maintained. The edge it traded stopped being worth trading once Robinhood began passing the per-contract regulatory fee through to customers — see Why it's archived. It is kept here for reference only.

A trading bot that scanned deep out-of-the-money SPY and QQQ option chains for crossed quotes, bought the resulting free vertical spreads, and immediately sold covered spreads against them to turn the position into cash.

It ran on Robinhood for a little over a year and made about $5,000. Robinhood then began passing the 3¢/contract regulatory fee through to customers, which left too little on the table to be worth the effort — see Why it's archived.

The strategy and the trading were Elie's. I built and ran the software.


The edge, briefly

Deep OTM options trade in one-cent ticks, which is coarse relative to the actual difference in value between adjacent strikes. Occasionally that makes two strikes cross:

SPY 400 put:  bid 0.03   ask 0.04
SPY 399 put:  bid 0.04   ask 0.05
                  ^^^^

Buying the 400 at its 0.04 ask and selling the 399 at its 0.04 bid is a net zero debit. You now hold a 1-wide vertical spread for free. Size varies: sometimes only a few contracts, often several hundred to a thousand.

Free spreads can be monetized right away by selling a wider spread against them. Holding 500 of the 400/399 spreads, with the 390 puts bid 0.02 and asked 0.03, you can sell up to 55 of the 399/390 spreads for a dollar each — the 399 hit at its 0.04 bid, the 390 bought at its 0.03 ask, so it is the ask that makes the trade — and they are fully covered by the spreads you already hold: the 1-wide spreads cover 1/9th of the 9-wide spreads you sold, so 500 ÷ 9 ≈ 55.

The alternative would be not to monetize at all and simply hold free extreme-tail exposure, waiting for a massive one- or two-day move in the S&P to make the spreads worth something. Because the minimum increment is a penny, that means waiting until the 400's bid is at least a penny above the 399's ask before any of it can be sold — these strikes are far enough out that even that is rare, let alone their finishing in the money. That variant ran as its own program alongside this one; here it survives as find_money(..., with_credit_leg=False), which finds the free spreads without pairing a credit leg to them.

Architecture

Three layers, each a single module:

Module Responsibility
broker.py Everything that talks to Robinhood: retries, session lifecycle, chain normalization, order placement
strategy.py Detection, position sizing, and the two-stage execution sequence
cli.py CLI, market-hours loop, clean logout

Detection — strategy.find_money

The chain is sorted numerically and reoriented so that moving forward through the list always means moving further out of the money, for calls and puts alike. Everything downstream depends on that invariant: it is what lets the scan stop early once quotes fall below the price floor, and what guarantees the credit leg is selected further out than the short.

Only options asking between $0.02 and $0.15 are considered. Below the floor the one-cent tick swamps the signal; above the ceiling these are no longer tail options.

For each candidate long leg the search runs from the furthest-out strike inwards, because the widest debit spread supports the most credit spreads.

Sizing — strategy.size_debit_order

The debit and credit spreads share the short strike, so the quoted size on that strike has to be divided between them in proportion to their widths. A 1-wide debit spread backing a 9-wide credit spread takes 9/10 of the available size. Sizing is capped by the quoted size on both legs and by Robinhood's 250-contract ceiling on any single order.

Credit spreads are then sold at 95% of what the debit spreads strictly cover. Nothing is ever sold that the spreads already in hand don't cover, so the margin isn't there to paper over the fills; it is insurance against some wild liquidity event, so that all the positions could still be exited at a net credit. Whether that was ever necessary or sufficient is an open question.

Execution — broker.order_spread

This is where most of the complexity lives, and it is complexity imposed entirely by the venue. Robinhood's execution is poor enough that placing an order once is close to useless — orders routinely sit unfilled until cancelled and re-placed, sometimes a dozen times or more. order_spread therefore:

  • re-places the order while nothing is filling, up to 25 attempts;
  • once something fills, waits out a 30-second quiet window, restarting the window each time more comes in, then takes whatever it has;
  • re-reads the order after cancelling, because contracts can fill in the gap between the fill check and the cancel landing.

Ordering matters: the debit leg is always bought first. The credit leg is only safe to sell once the covering spreads are actually held.

Reliability — broker.protect

Every Robinhood call is wrapped in a retry with quadratic backoff. When the retry budget is exhausted the process replaces its own image via os.execv and starts over.

That is deliberate rather than defensive sloppiness. Authentication was the least reliable part of the system, and recovering by re-running the login flow in a fresh process proved more dependable than trying to nurse a wedged session back to life in place.

Running it

Dependencies are managed with uv; uv.lock pins the whole tree.

uv sync
cp credentials.example.json robinhood_credentials.json   # then fill it in
uv run spread-arb --symbol spy --name User

robinhood_credentials.json is gitignored and is read from the working directory. --symbol accepts spy or qqq. The bot scans until 16:00 and logs out cleanly on exit, including when the market closes mid-scan.

uv run pytest

Tests cover detection, chain parsing and sizing — everything that is pure given a chain — and need no account, network, or even robin_stockstests/conftest.py stubs the package out when it is absent. The order placement loop is deliberately not tested: its behavior is defined by how Robinhood responds to partial fills, and a stub broker would only test the stub.

Why it's archived

Any fees at all destroy this, which is why it could only ever run on Robinhood — until October 2023 the only brokerage we could find that both charged nothing for options and absorbed the regulatory fees, which run 3–6¢ per contract bought or sold, rather than passing them on.

Once the 3¢ is passed through, the example above earns $55 gross and a little over $20 net. Worse, it stops being risk-free: buying the initial 500 spreads now costs $30, and there is a delay before the covering spreads can be sold — lengthened by Robinhood's cancel-and-re-place execution — during which the market can move. Previously the worst case was ending up with free tail exposure you couldn't immediately monetize.

The tail exposure — 6¢ per spread for something that pays up to $100, however rarely — may or may not still be worth buying, depending on the level of implied volatility: back out the implied vol of the spread, compare it to that of nearby spreads, and it will look either cheap or expensive. What is not worth it either way is the effort of running and monitoring this.

Known limitations

  • The os.execv restart loses the running P&L total for the day; the positions are unaffected, but the reported total resets.
  • The scan loop has no delay between passes. That is intentional — the edge is latency-sensitive — but it does mean the bot is hard on the API.
  • The hold-the-spread variant is detected but has no runner here; only the monetizing path is wired to the CLI.

What is actually in this repo (August 2026)

The bot as it ran was three files — run.py, moneyMachine.py, back.py, about 400 lines of code.

Years after the strategy stopped being tradeable, I overhauled the project with Claude Code and released it to the public. Nothing about the strategy changed; the point was to make the thing legible enough to be educational or interesting for anyone who wants to take a look1. Claude:

  • reorganized all of the code into new files
  • wrote lots of comments
  • fixed some kludges that were specific to my deployment
  • wrote tests
  • wrote this README

Note: the rewritten code has never been run against a live account. It is equivalent to the original as far as reading and the tests can establish, and no further than that.

Credits

Strategy, trading, and the writeup: Elie.
Software: me (@YSCohen)

Code is dual-licensed MIT or Apache-2.0. tweet.md is Elie's, reproduced with permission and not covered by that license.

Nothing here is investment advice. It is published as a record of something that used to work, and it comes with no warranty — see the licenses.

Footnotes

  1. And, of course, because I wanted to list this project on my resume 🙂

About

Far-OTM vertical spread arbitrage on Robinhood (archived)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages