| name | spy-trading-bot |
| description | Automated SPY 0DTE call options trading bot using Robinhood. Monitors SPY price, buys a 0DTE call option, and sells at 3% profit. Use when user says 'run spy bot', 'trade spy', 'spy options', or 'start trading bot'. |
SPY 0DTE Options Trading Bot
You are executing an automated trading strategy. Follow each phase sequentially. Read CLAUDE.md for current trading parameters before starting.
Trading parameters (read from CLAUDE.md, these are defaults):
- TARGET_RETURN: 3%
- STOP_LOSS: -10%
- MAX_CONTRACTS: 10
- ORDER_TYPE: limit
- EXIT_BEFORE_CLOSE_MINUTES: 10
- DRY_RUN: true
Phase 0: Pre-flight Checks
-
Read the CLAUDE.md file in the spy-bot project to get current trading parameters. Note whether DRY_RUN is true or false.
-
Check if Chrome is running with remote debugging:
curl -s http://localhost:9222/json
If it fails, launch Chrome:
bash ~/spy-bot/launch-chrome.sh
Wait a few seconds, then verify again.
- Check the current time in Eastern Time:
TZ='America/New_York' date '+%A %H:%M'
- If it's a weekend (Saturday/Sunday), abort: "Market is closed (weekend)."
- If before 9:30 AM ET or after 4:00 PM ET, abort: "Outside market hours."
If all checks pass, proceed to Phase 1.
Phase 1: Navigate to SPY Options Chain
-
Use browser_navigate to go to: https://robinhood.com/options/chains/SPY
-
Use browser_snapshot to capture the page state.
-
Verify login: Look at the snapshot for signs of being logged in:
- If you see a login form, email/password fields, or "Log In" button → abort with: "Not logged into Robinhood. Please log in manually and retry."
- If you see the options chain with strike prices, calls/puts columns → you're good, proceed.
Phase 2: Read SPY Price
- Wait 3 seconds for the page to settle:
sleep 3
-
Take a browser_snapshot and identify the element displaying the current SPY price. Look for the main price display near the top of the page — it will show a dollar amount like "$542.37".
-
Read the current SPY price. IMPORTANT: Robinhood uses animated CSS digit rollers for the main price display — MutationObservers do NOT work on them. Instead, read the price from the header link elements which contain plain text like "$709.18 (+0.72%)". Use browser_evaluate:
(() => {
const readPrice = () => {
const links = document.querySelectorAll('a');
for (const a of links) {
const match = a.textContent.match(/\$([\d,]+\.\d{2})/);
if (match) {
const price = parseFloat(match[1].replace(/,/g, ''));
if (price > 400 && price < 900) return price;
}
}
return null;
};
window.__READ_PRICE = readPrice;
return { currentPrice: readPrice() };
})()
- Record the current SPY price. The key output of this phase is: the current SPY price.
Phase 3: Determine Cash & Select Strike
-
Take a browser_snapshot of the options chain page.
-
Find today's expiration: Look in the snapshot for an expiration date selector. You need today's date (0DTE). If today's expiration is not already selected, click on the expiration selector and choose today's date.
-
Identify the ATM strike: Using the current SPY price from Phase 2, find the call option with a strike price closest to (but not above) the current price. For example, if SPY is at $542.37, look for the $542 strike call.
-
Read the ask price for that call option from the options chain. Take note of it.
-
Find buying power: Look in the snapshot for buying power or account balance. It may appear in a sidebar, order panel, or you may need to navigate to account. If not visible on the options chain page, try:
- Look for a "Buying Power" or "Cash Available" label in the snapshot
- If not found, navigate to
https://robinhood.com/account briefly to check, then come back
-
Calculate quantity:
contracts = floor(buying_power / (ask_price * 100))
- Cap at MAX_CONTRACTS from CLAUDE.md
- Must be at least 1. If buying power is insufficient for even 1 contract, abort: "Insufficient buying power."
-
Report what you're about to do:
- "SPY Price: $XXX.XX"
- "Strike: $XXX (0DTE Call)"
- "Ask Price: $X.XX per contract"
- "Quantity: N contracts"
- "Total Cost: $X,XXX.XX"
- "Buying Power: $X,XXX.XX"
Phase 4: Place Buy Order
If DRY_RUN is true: Report what you would do and skip to Phase 7 with simulated results. Do NOT click any buy/submit buttons.
If DRY_RUN is false:
-
Open the order form: Click the Ask Price button (e.g. "$3.06") on the ATM strike row in the options chain grid. This populates the order panel on the right sidebar. Do NOT just click the row — clicking the row only expands an inline detail view. You must click the Ask Price button specifically.
-
Read the order panel content. Use browser_evaluate to find the panel by searching for the element containing "buying power available", then walk up to its container. The panel shows:
- Order type (should say "Long Call")
- Contracts input (aria-label shows current value, e.g. "1")
- Limit Price input (aria-label shows current value, e.g. "$3.06")
- Time in Force button (should say "Good for Day")
- Estimated cost
- "Review order" button
-
Set the quantity: The Contracts input defaults to 1. If you need more contracts:
- Take a
browser_snapshot to find the Contracts input ref
- Click the input, clear it, and type the desired number
- Verify the estimated cost updates correctly
-
Verify the order: The Limit Price should already be set to the ask price. Time in Force should be "Good for Day". Check the estimated cost matches expectations.
-
Click the "Review order" button.
-
Take a browser_snapshot of the review/confirmation screen. Verify:
- The order details match what you intended
- The total cost is correct
-
Click "Submit" or "Place Order" or "Confirm".
-
Take a browser_snapshot to verify the order was submitted. Look for:
- "Order placed" or "Order submitted" confirmation
- If you see an error (insufficient funds, rejected, etc.), report it and abort.
-
Wait for the fill. Check every 500ms for up to 60 seconds using browser_evaluate to poll for fill status:
- Look for "Filled" status or a fill price in the order confirmation area
- If not filled after 60 seconds, cancel the order and abort.
-
Record the fill price as ENTRY_PRICE. Store it in the browser:
(() => {
window.__ENTRY_PRICE = ;
window.__ENTRY_TIME = Date.now();
window.__NUM_CONTRACTS = ;
return { entryPrice: window.__ENTRY_PRICE, contracts: window.__NUM_CONTRACTS };
})()
Phase 5: Monitor Position (High-Frequency)
-
Navigate to your position. This might be on the same page, or you may need to go to:
- The order confirmation page (which often shows live P&L)
https://robinhood.com/options/chains/SPY and find your position
- Or the portfolio page
-
Take a browser_snapshot to identify where the current option price / P&L is displayed. Identify the DOM element showing the option's current price or your position's current value. Note its text content pattern.
-
Inject the high-frequency monitoring script via browser_evaluate. IMPORTANT: Before injecting, you must first identify which DOM element shows the option's current mark/price on the position page from the snapshot above. Then use that to build the price reader.
The monitoring approach that works on Robinhood (tested): read prices from link elements or text nodes that match $N.NN patterns. The animated digit rollers do NOT work with MutationObservers — they use CSS transforms.
The script handles ALL sell-signal logic in-browser — including target, stop-loss, AND time-based exit. The time cutoff is computed once as an absolute timestamp so there are no external bash calls needed during monitoring.
((entryPrice, targetReturn, stopLoss, exitBeforeCloseMinutes) => {
window.__ENTRY_PRICE = entryPrice;
window.__SELL_SIGNAL = null;
window.__PRICE_LOG = [];
window.__MONITOR_STARTED = Date.now();
const nowET = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' }));
const closeET = new Date(nowET);
closeET.setHours(16, 0, 0, 0);
const cutoffET = new Date(closeET.getTime() - exitBeforeCloseMinutes * 60 * 1000);
const etOffset = nowET.getTime() - Date.now();
const cutoffTimestamp = cutoffET.getTime() - etOffset;
const readOptionPrice = () => {
const links = document.querySelectorAll('a');
for (const a of links) {
const match = a.textContent.match(/\$([\d,]+\.\d{2})/);
if (match) {
const price = parseFloat(match[1].replace(/,/g, ''));
if (price > 0 && price < 100) return price;
}
}
const allEls = document.querySelectorAll('*');
for (const el of allEls) {
if (el.children.length > 0) continue;
const text = el.textContent?.trim();
const match = text?.match(/^\$?([\d]+\.\d{2})$/);
if (match) {
const val = parseFloat(match[1]);
if (val > 0.01 && val < 100) return val;
}
}
return null;
};
let lastKnownPrice = entryPrice;
window.__MONITOR = setInterval(() => {
try {
const price = readOptionPrice();
if (price) lastKnownPrice = price;
const returnPct = ((lastKnownPrice - entryPrice) / entryPrice) * 100;
window.__LATEST_RETURN = {
currentPrice: lastKnownPrice,
entryPrice,
returnPct,
time: Date.now(),
elapsed: Date.now() - window.__MONITOR_STARTED
};
window.__PRICE_LOG.push({ price: lastKnownPrice, returnPct, time: Date.now() });
if (window.__PRICE_LOG.length > 1000) {
window.__PRICE_LOG = window.__PRICE_LOG.slice(-500);
}
if (returnPct >= targetReturn) {
window.__SELL_SIGNAL = 'TARGET_HIT';
window.__SELL_RETURN = returnPct;
window.__SELL_PRICE = lastKnownPrice;
clearInterval(window.__MONITOR);
} else if (returnPct <= -Math.abs(stopLoss)) {
window.__SELL_SIGNAL = 'STOP_LOSS';
window.__SELL_RETURN = returnPct;
window.__SELL_PRICE = lastKnownPrice;
clearInterval(window.__MONITOR);
} else if (Date.now() >= cutoffTimestamp) {
window.__SELL_SIGNAL = 'TIME_CUTOFF';
window.__SELL_RETURN = returnPct;
window.__SELL_PRICE = lastKnownPrice;
clearInterval(window.__MONITOR);
}
} catch (err) {}
}, 100);
return 'Monitor started. Entry: $' + entryPrice + ', Target: +' + targetReturn + '%, Stop: -' + stopLoss + '%, Cutoff: ' + new Date(cutoffTimestamp).toISOString();
})(ENTRY_PRICE_HERE, TARGET_RETURN_HERE, STOP_LOSS_HERE, EXIT_BEFORE_CLOSE_MINUTES_HERE)
Replace ENTRY_PRICE_HERE, TARGET_RETURN_HERE, STOP_LOSS_HERE, and EXIT_BEFORE_CLOSE_MINUTES_HERE with the actual values before injecting.
NOTE: The readOptionPrice function above is a starting point. After the buy is filled, take a snapshot of the position page and adapt the price reader to target the specific element showing the option's current value. The exact DOM structure will depend on which page you're viewing the position from.
- Poll for sell signal — every 1 second, check via
browser_evaluate:
(() => {
return {
sellSignal: window.__SELL_SIGNAL,
latest: window.__LATEST_RETURN,
sellPrice: window.__SELL_PRICE,
monitorRunning: !!window.__MONITOR
};
})()
All sell-signal logic (target, stop-loss, time cutoff) runs in-browser at 100ms intervals. The 1-second poll here is just to read the result — the browser JS has already detected the signal.
-
When a sell signal is detected (TARGET_HIT, STOP_LOSS, or TIME_CUTOFF), immediately proceed to Phase 6.
-
If no signal yet, use ScheduleWakeup with 1-second intervals to keep polling. Each wakeup should check the sell signal, then schedule the next wakeup if no signal yet. Do NOT run any bash time checks — the in-browser JS handles time cutoff automatically.
-
Monitor crash recovery: If monitorRunning is false but no sellSignal was set, re-inject the monitoring script.
Phase 6: Sell Position
-
Take a browser_snapshot to see the current page state.
-
Find and click the "Sell" button for your position. This might be:
- A "Sell" button on the position card
- A "Close" or "Close Position" button
- You may need to navigate to your positions first
-
Take a browser_snapshot of the sell order form.
-
Fill in the sell order:
- Quantity: All contracts (the same number you bought)
- Order Type: Market (for speed — we need to sell NOW when the signal fires)
- If market orders aren't available for options, use Limit at the current bid price
-
Click "Review" → take browser_snapshot → verify details → click "Submit"/"Confirm".
-
Take a browser_snapshot to verify the sell order was submitted and filled.
-
Record the sell/exit price.
Phase 7: Report Results
Print a formatted summary:
═══════════════════════════════════════
SPY 0DTE Trading Bot — Results
═══════════════════════════════════════
Strike: $XXX (0DTE Call)
Contracts: N
Entry: $X.XX per contract
Exit: $X.XX per contract
Total Cost: $X,XXX.XX
Total Proceeds: $X,XXX.XX
Return: +X.XX%
P&L: +$XXX.XX
Duration: Xm Xs
Trigger: TARGET_HIT / STOP_LOSS / TIME_CUTOFF
═══════════════════════════════════════
If DRY_RUN was true, label the report as "[DRY RUN — No actual trades executed]" and show what would have happened based on simulated/observed prices.
Error Handling
Throughout all phases:
- Element not found: If a snapshot doesn't show the expected element, wait 3 seconds and re-snapshot. Retry up to 3 times. If still missing, take a screenshot for debugging and abort with a clear error message.
- Navigation failure: If a page doesn't load, retry once. If it fails again, abort.
- Order issues: If an order is rejected or fails, report the exact error message from the page and abort. Do not retry failed orders.
- Monitor crash: If the in-browser monitor stops running (check
monitorRunning in the poll), re-inject it.
- Unexpected dialogs: If a dialog/popup appears, take a snapshot, read it, and handle appropriately (dismiss if it's a notification, abort if it's an error).