Bull Call Spread — Real-World Execution
Learn how to execute a bull call spread with actual options chain data: strike selection, bid-ask slippage, executability assessment, and practical order entry.
Bull Call Spread — Real-World Execution
The CRR pricing model is mathematically beautiful, but theory meets reality when you hit the "buy" button. Real options chains have discrete strikes, wide bid-ask spreads, and liquidity that varies by strike. This lesson teaches you how to go from "I want a 30 DTE ATM/5% OTM spread" to "I just filled both orders at $2.65 net debit." We'll cover strike selection, slippage calculations, executability scoring, and practical order sequencing.
Step 1 — Understanding Bid-Ask Slippage
When you trade options, you never fill at the theoretical "mid" price. Instead:
- You BUY your long call AT THE ASK (paying what sellers want)
- You SELL your short call AT THE BID (getting what buyers will pay)
- This gap between mid and your actual fills is slippage.
// Example: SPY $450 ATM call, $455 5% OTM call (both 30 DTE)
const longLeg = {
strike: 450,
bid: 4.50,
mid: 4.65, // theoretical mid-market price
ask: 4.80, // what you'll PAY to buy
volume: 100,
openInterest: 500,
};
const shortLeg = {
strike: 455,
bid: 2.00, // what you'll RECEIVE to sell
mid: 2.15,
ask: 2.30,
volume: 150,
openInterest: 600,
};
// Theory: pay $2.50 per share (4.65 - 2.15)
const theoreticalDebit = (longLeg.mid - shortLeg.mid) * 100; // $250 per contract
// Reality: pay $2.80 per share (4.80 - 2.00)
const realisticDebit = (longLeg.ask - shortLeg.bid) * 100; // $280 per contract
// Slippage cost
const slippage = realisticDebit - theoreticalDebit; // $30 per contract = 12%| Metric | Long Call | Short Call | Impact |
|---|---|---|---|
| Your Fill | Ask $4.80 | Bid $2.00 | Pay worst-case |
| Theoretical | Mid $4.65 | Mid $2.15 | Never happens in practice |
| Your Cost | +$0.15 vs mid | −$0.15 vs mid | +$0.30 total slippage |
| As % of Spread | (0.30 / $10) × 100 | ─ | 3% of spread width |
Step 2 — Filter Chain by Liquidity
Not all strikes are equally liquid. Illiquid strikes have wide bid-ask spreads that will destroy your P&L. Always filter BEFORE selecting strikes.
import { filterByLiquidity, ChainOption } from 'bull-call-spread-strategy';
const chain: ChainOption[] = await getOptionsChain('AAPL', '2026-05-15');
// Minimum thresholds for liquid strikes
const liquid = filterByLiquidity(chain, minVolume=50, minOI=500);
console.log(`Raw chain: ${chain.length} strikes`);
console.log(`Liquid strikes: ${liquid.length} strikes`);
// Now select strikes from the liquid subset, not the full chain
const strikes = [...new Set(liquid.map(o => o.strike))].sort((a, b) => a - b);
const atm = selectStrike(spot * 1.0, strikes); // find $450 (or closest)
const otm = selectStrike(spot * 1.05, strikes); // find $472.50 (or closest)from bull_call_spread_strategy import filter_by_liquidity, select_strike
chain = await get_options_chain('AAPL', '2026-05-15') # e.g., fetch from broker API
# Minimum thresholds for liquid strikes
liquid = filter_by_liquidity(chain, min_volume=50, min_open_interest=500)
print(f'Raw chain: {len(chain)} strikes')
print(f'Liquid strikes: {len(liquid)} strikes')
# Now select strikes from the liquid subset, not the full chain
strikes = sorted(set(o.strike for o in liquid))
atm = select_strike(spot * 1.0, strikes) # find $450 (or closest)
otm = select_strike(spot * 1.05, strikes) # find $472.50 (or closest)Liquidity Matters More Than Theory
A theoretically perfect strategy (2:1 risk/reward) with 1% bid-ask slippage is worse than a mediocre strategy (1.5:1 R/R) with 0.2% slippage. Wide spreads erode edge faster than anything else. If volume < 10 on either leg, skip the trade and wait.
Step 3 — Assess Executability
import { analyzeSpreadFromChain, assessExecutability } from 'bull-call-spread-strategy';
const config = {
lowStrikeOffset: 0.0, // ATM long
highStrikeOffset: 0.05, // 5% OTM short
daysToExpiry: 30,
contracts: 1,
};
const analysis = analyzeSpreadFromChain(spot, liquidChain, config);
const assessment = assessExecutability(analysis);
// Executability Score: 0–100
// 100 = Maximum liquidity (execute immediately)
// 70 = Safe to trade (execute with limit orders)
// 0 = Too illiquid (skip this trade)
console.log(`Score: ${assessment.score}/100`);
console.log(`Execute Now: ${assessment.executeNow ? '✅' : '❌'}`);
if (!assessment.executeNow) {
console.warn('Warnings:');
assessment.warnings.forEach(w => console.warn(` - ${w}`));
return; // skip this trade
}from bull_call_spread_strategy import (
BullCallSpreadStrategy, analyze_spread_from_chain, assess_executability
)
config = BullCallSpreadStrategy(
low_strike_offset=0.0, # ATM long
high_strike_offset=0.05, # 5% OTM short
days_to_expiry=30,
contracts=1,
)
analysis = analyze_spread_from_chain(spot, liquid_chain, config)
assessment = assess_executability(analysis)
# Executability Score: 0–100
# 100 = Maximum liquidity (execute immediately)
# 70 = Safe to trade (execute with limit orders)
# 0 = Too illiquid (skip this trade)
print(f'Score: {assessment.score}/100')
print(f'Execute Now: {"✅" if assessment.execute_now else "❌"}')
if not assessment.execute_now:
print('Warnings:')
for w in assessment.warnings:
print(f' - {w}')
return # skip this trade| Score Range | Assessment | Action |
|---|---|---|
| 90–100 | Excellent liquidity | Use market orders during peak hours |
| 70–89 | Good liquidity | Use limit orders, likely to fill same day |
| 50–69 | Mediocre liquidity | Use tight limit orders or skip |
| < 50 | Poor liquidity | ❌ Do not trade this strike combination |
Step 4 — Compare Theoretical vs Realistic Costs
import { compareCosts } from 'bull-call-spread-strategy';
const costs = compareCosts(analysis);
console.log(`
Theoretical Debit: $${costs.theoreticalDebit.toFixed(2)} (mid prices)
Realistic Debit: $${costs.realisticDebit.toFixed(2)} (ask/bid fills)
Slippage: $${costs.slippageDollars.toFixed(2)} ($${costs.slippagePct.toFixed(1)}%)
Max Profit Adjusted:
Theory: $${(analysis.maxProfit).toFixed(2)}
Reality: $${(analysis.maxProfit - costs.slippageDollars).toFixed(2)} (lower due to slippage)
`);
// Example output:
// Theoretical Debit: $250.00
// Realistic Debit: $280.00
// Slippage: $30.00 (12.0%)
// Max Profit Adjusted: $720.00 → $690.00from bull_call_spread_strategy import compare_costs
costs = compare_costs(analysis)
print(f'''
Theoretical Debit: ${costs.theoretical_debit:.2f} (mid prices)
Realistic Debit: ${costs.realistic_debit:.2f} (ask/bid fills)
Slippage: ${costs.slippage_dollars:.2f} ({costs.slippage_pct:.1f}%)
Max Profit Adjusted:
Theory: ${analysis.max_profit:.2f}
Reality: ${analysis.max_profit - costs.slippage_dollars:.2f} (lower due to slippage)
''')
# Example output:
# Theoretical Debit: $250.00
# Realistic Debit: $280.00
# Slippage: $30.00 (12.0%)
# Max Profit Adjusted: $720.00 → $690.00Step 5 — Execute the Spread
- BUY the long call FIRST at your limit (at or better than ASK)
- WAIT for confirmation fill (usually instant during liquid hours)
- SELL the short call IMMEDIATELY after (at or better than BID)
- Recommended: use a spread order if your broker supports it (executes both legs atomically)
- Avoid market orders at market open/close—use 10 AM–3 PM ET for best fills
// Execution Example: AAPL Spread
// LEG 1: Buy the long call
BUY 1 AAPL May 190 Call @ $5.70 limit
// Your broker might fill this @ $5.65 (better)
// LEG 2: Immediately after long fills, sell the short
SELL 1 AAPL May 195 Call @ $2.10 limit
// Your broker might fill this @ $2.15 (slightly worse)
// Net Result:
// Entry Cost: ($5.65 - $2.15) × 100 = $350
// vs Theory: ($5.55 - $2.25) × 100 = $330
// Slippage: $20 (6% cost)Never "Leg Into" Spreads During Volatility
If your long fills but your short doesn't execute the same minute, you're temporarily naked long a call. If the market gaps 2% in that 5 minutes, your "defined-risk" spread suddenly has $500 of risk. Always use spread orders when available, or leg in during the calmest market hours.
Step 6 — Calculate Greeks with Real IVs
Each strike in the options chain has its own implied volatility (IV varies by strike—this is called "volatility skew"). Use each leg's actual IV, not a flat IV assumption.
// Each leg gets its own IV from the chain
const longGreeks = crrGreeks({
spotPrice: 190,
strikePrice: 190, // ATM long call
volatility: longLeg.iv, // 0.22 (lower IV at ATM)
timeToExpiry: 0.082, // 30 days
riskFreeRate: 0.05,
});
const shortGreeks = crrGreeks({
spotPrice: 190,
strikePrice: 195, // OTM short call
volatility: shortLeg.iv, // 0.20 (even lower IV OTM)
timeToExpiry: 0.082,
riskFreeRate: 0.05,
});
const netGreeks = {
delta: longGreeks.delta - shortGreeks.delta, // ~0.28 (bullish)
theta: longGreeks.theta - shortGreeks.theta, // ~+$0.40/day (positive)
vega: longGreeks.vega - shortGreeks.vega, // ~−$0.05 (short vega)
};
console.log(`Net Delta: ${netGreeks.delta.toFixed(2)} → bull on $1 moves`);
console.log(`Daily Theta: +$${netGreeks.theta.toFixed(2)} → profit from time`);
console.log(`Vega Exposure: ${netGreeks.vega.toFixed(2)} → hurt by IV spikes`);from bull_call_spread_strategy import crr_greeks
# Each leg gets its own IV from the chain
long_greeks = crr_greeks(
S=190, K=190, # ATM long call
T=0.082, # 30 days
r=0.05,
sigma=long_leg.iv, # 0.22 (lower IV at ATM)
option_type='call',
steps=100
)
short_greeks = crr_greeks(
S=190, K=195, # OTM short call
T=0.082,
r=0.05,
sigma=short_leg.iv, # 0.20 (even lower IV OTM)
option_type='call',
steps=100
)
net_greeks = {
'delta': long_greeks['delta'] - short_greeks['delta'], # ~0.28 (bullish)
'theta': long_greeks['theta'] - short_greeks['theta'], # ~+$0.40/day
'vega': long_greeks['vega'] - short_greeks['vega'], # ~−$0.05 (short vega)
}
print('Net Delta: {:.2f} → bull on $1 moves'.format(net_greeks["delta"]))
print('Daily Theta: +${:.2f} → profit from time'.format(net_greeks["theta"]))
print('Vega Exposure: {:.2f} → hurt by IV spikes'.format(net_greeks["vega"]))Step 7 — Set Profit Targets & Stop Losses
| Exit Rule | Trigger | Benefit |
|---|---|---|
| Profit Target | Spread value ≤ 70% of entry | Lock in $$ before momentum reverses |
| Stop Loss | Spread value ≥ 110% of entry | Protect capital; don't let losers run |
| Time Exit | Close at 21 DTE remaining | Avoid final week gamma risk; reduce position size |
// Example: AAPL spread entered at $3.50 net debit
const entryDebit = 3.50;
const maxProfit = 10.00 - entryDebit; // $10 spread width - $3.50 cost = $6.50
// Exit rules per contract:
const profitTargetValue = entryDebit - (maxProfit * 0.70); // $3.50 - $4.55 = close @ $2.75
const stopLossValue = entryDebit * 1.10; // $3.85
// Daily monitoring:
if (spreadValue <= profitTargetValue) {
closeSpread('PROFIT_TARGET__70_percent_max_reached');
} else if (spreadValue >= stopLossValue) {
closeSpread('STOP_LOSS__110_percent_entry_cost');
} else if (daysToExpiry <= 21) {
closeSpread('TIME_EXIT__roll_to_next_month');
}# Example: AAPL spread entered at $3.50 net debit
entry_debit = 3.50
max_profit = 10.00 - entry_debit # $10 spread width - $3.50 cost = $6.50
# Exit rules per contract:
profit_target_value = entry_debit - (max_profit * 0.70) # $3.50 - $4.55 = close @ $2.75
stop_loss_value = entry_debit * 1.10 # $3.85
# Daily monitoring:
if spread_value <= profit_target_value:
close_spread('PROFIT_TARGET__70_percent_max_reached')
elif spread_value >= stop_loss_value:
close_spread('STOP_LOSS__110_percent_entry_cost')
elif days_to_expiry <= 21:
close_spread('TIME_EXIT__roll_to_next_month')Full Execution Flow (Real Example)
Here's a complete workflow from market data to order entry:
- Fetch options chain for AAPL May monthly expiry
- Filter to liquid strikes only (volume > 100, OI > 1000)
- Calculate spreads for all ATM/5% OTM combos
- Score each candidate using assessExecutability()
- Pick highest-scored candidate with good theta
- Leg in: Buy long call @ ASK, then sell short call @ BID
- Monitor daily: Mark spread to market every morning
- Close early if profit target hit, stop loss triggered, or 21 DTE
Key Takeaway: Account for Slippage ALWAYS
12% slippage sounds extreme, but it's real on illiquid strikes. On liquid strikes (SPY, QQQ), slippage is often 1–3%. On small-cap stocks, it's 5–10%. Know your slippage before entering; if it exceeds 10% of your max loss, skip the trade or wait for better liquidity.
- Use real options chain data to select strikes (filter by liquidity first)
- Account for bid-ask slippage: buy long at ASK, sell short at BID
- Assess executability before trading—score < 70 means skip
- Execute long leg first, then short leg; use limit orders during volatile hours
- Monitor daily P&L and close at 70% of max profit or 110% of entry cost