The Bull Call Spread Explained (and How to Trade The Options Strategy with Alpaca)

A bull call spread is a type of debit spread that is designed for traders anticipating a moderate increase in an underlying asset's price. In this tutorial, we will explore the fundamentals of a bull call spread, providing real-world examples and discussing how market factors, including the Greeks, influence their performance. We will also explore the risks and nuances that every trader should understand before executing this strategy.

By breaking down the mechanics and construction of a bull call spread, our guide aims to equip readers with the knowledge needed to harness volatility and optimize their trading approach effectively.  Then, we will highlight actionable steps you can take for algorithmic integration using Alpaca's Trading API or manual trading using Alpaca’s dashboard.

What is a Bull Call Spread?

The bull call spread is an advanced options trading strategy designed to seek potential gains from a moderate increase in an underlying asset's price. This strategy involves purchasing a call option at a lower strike price and simultaneously selling another call option at a higher strike price. Both options use the same expiration date and underlying asset. The primary objective is to capitalize on the asset's upward movement while limiting both potential profits and losses.

To create a bull call spread, a trader wants to build positions as:

  • Buying (longing) one call option at a lower strike price.
  • Selling (shorting) another call option at a higher strike price.

This setup creates a defined-risk, defined-reward position, often used by traders who expect the underlying price to rise modestly—but not necessarily skyrocket.

When to Use a Bull Call Spread

The bull call spread is a strategy designed when traders may anticipate a moderate rise in the price of the underlying asset. This may make it particularly useful in market environments where there is some optimism about price appreciation, but not the expectation of an explosive upward move. Compared to buying a single call option, this spread reduces the upfront cost by selling a higher strike option, which helps offset the cost of buying the lower strike option.

The following market conditions may be when to utilize a bull call spread strategy:

  • Moderate bullish outlook: The trader expects the underlying asset to increase modestly over the life of the options, but not necessarily surge to significantly higher levels.
  • Stable volatility: When implied volatility (IV) is relatively stable, the premiums for both calls are less inflated, making it easier to enter the spread at a reasonable net debit.

On the other hand, there are certain situations where a bull call spread may not be optimal:

  • Highly bullish expectations: If traders expect a sharp and substantial increase in the underlying price. The capped profit potential of the bull call spread may limit gains in a fast-rising market.
  • Bearish or uncertain outlook: If the trader believes the price will remain flat or decline, a bull call spread may lose its entire premium, since both calls would expire worthless.
  • Narrow price movement (low volatility risk): If the underlying asset’s price barely moves, neither call option will gain much value, and the trader risks losing the net debit paid.

Short-term time decay (Theta risk): Like any options strategy, the passage of time works against the bull call spread. If the underlying price doesn’t move favorably, the options’ time value will gradually decay, which can erode the spread’s value before expiration.

Bull Call Spread Example (With Payoff Diagram)

To understand how a bull call spread performs under various market conditions, let's analyze its example using stock XYZ as the underlying asset. This will help illustrate the risk-reward profile of a bull call spread, where the theoretical maximum loss is limited to the initial net debit of $2.00 per share, and the profit potential is theoretically limited to $5.00 when the price increases moderately.

Example Setup:

  • Stock price at initiation: XYZ is trading at $100
  • Net Debit (Total Cost): $2.00 - $4.00 = - $2.00 (- $200 total)
  • Breakeven Points: $100 (lower strike) + $2 (initial debit) = $102

Trade Structure:

Action

Option Type

Strike Price

Premium

Sell (Short)

Call

$105

$2.00 ($200 total)

Buy (Long)

Call

$100

$4.00 ($400 total)

Potential Outcomes at Expiration:

Scenario 1: Stock remains at $100 (at the long call strike)

  • If XYZ’s stock price remains at $100 or below at expiration, both call options expire worthless. The long call (strike $100) finishes at-the-money (ATM) or out-of-the-money (OTM), and the short call (strike $105) also expires worthless.
  • The trader loses the entire net debit of $2 per share ($200 total for one spread).
  • Net loss: $0 (both calls expire) - $200 (initial debit) = − $200 (theoretical maximum loss).

Scenario 2: Stock rises to $102 (breakeven point)

  • At $102, the long call (strike $100) has $2 of intrinsic value, while the short call (strike $105) is still OTM and expires worthless.
  • This $2 of intrinsic value exactly offsets the initial $2 net debit paid to enter the spread. The trader breaks even at this price.
  • Net even: - $10,000 (exercising long call) + $10,200 (selling 100 shares at the market price) - $200 (initial debit) = $0 (break-even).

Scenario 3: Stock rises to $110 (above both strikes)

  • At $110, the long call (strike $100) is deep in-the-money (ITM) with $10 of intrinsic value. However, the short call (strike $105) is also ITM with $5 of intrinsic value. Because the trader is long the $100 call and short the $105 call, the spread is capped at a theoretical maximum value of: $5 ($105−$100).
  • After subtracting the initial $2 debit, the trader’s net profit is $3 per share ($300 total for one spread).
  • Net profit: $10,500 (assigned short call) - $10,000 (exercising long call) - $200 (initial debit) = $300 (theoretical maximum profit)

Key Metrics:

  • Theoretical Maximum Loss: The net debit paid to enter the spread ($2.00 per share or $200 total).​
  • Theoretical Maximum Profit: The difference between the strike prices minus the net debit: ($5.00 - $2.00) = $3.00 per share ($300 total).​

Note: For American-style options, there's a possibility of early assignment of the short call, especially around ex-dividend dates. This can affect the strategy's outcome before the expiration date.

Bull Call Payoff Diagram: Profit/loss diagram with $100 long call strike, $105 short call, and break-even point at $102.00
Bull Call Spread strategy: Profit/loss diagram

Bull Call Spread vs. Bull Put Spread

Both bull call spreads and bull put spreads are similar in that both aim to profit from bullish market, but they differ in construction, cost, and risk profiles.​

Feature

Bull Call Spread

Bull Put Spread

Market Outlook

Moderately Bullish

Neutral to Moderately Bullish

Strategy Type

Debit Spread

Credit Spread

Structure

Buy Lower Strike Call, Sell Higher Strike Call

Sell Higher Strike Put, Buy Lower Strike Put

Cost

Requires a net debit (premium paid).

Results in a net credit (premium received).

Risk and Reward

Limited risk, limited reward; max loss is the net debit, max profit is capped by the spread width

Limited risk, limited reward; max loss is the difference between strikes minus the net credit, max profit is the net credit received

Understanding the Options Greeks in a Bull Call Spread

To optimize strategy execution when using a bull call spread, it is essential to monitor volatility trends, price movements, and expiration timelines, as various option Greek factors can influence the outcome.

Delta

  • The strategy has a net positive delta, meaning it gains value as the stock price rises. Net delta is calculated by subtracting the short call’s delta from the long call’s delta. 
  • Unlike a single long call, a bull call spread’s delta remains relatively stable due to its low gamma. 
  • The impact of delta depends on the stock price’s position relative to the strike prices:
    • Near the long call’s strike price: The net delta is highest.
    • Between the strikes: The net delta is positive but diminishes.
    • Near the short call's strike price: The net delta can become negative.​

Gamma

  • Compared to a single long call, the spread’s gamma is lower, reducing the extent of delta fluctuations. 
  • Net gamma is calculated by subtracting the short call’s gamma from the long call’s gamma. 
  • The impact of gamma depends on the stock price’s position relative to the strike prices:
    • Near the long call’s strike price: The net gamma is positive.
    • Between the strikes: The net gamma remains positive but diminishes.
    • Near the short call's strike price: The net gamma can become negative.​

Theta

  • As a debit strategy, time decay generally works against the position.
  • The impact of theta depends on the stock price’s position relative to the strike prices:
    • Near the long call’s strike price: The net theta can be negative.
    • Between the strikes: The net theta can be negative.
    • Near the short call’s strike price: The net theta is positive.

Vega:

  • The vega of a bull call spread is lower compared to a single long call—due to the offsetting effects of the long and short calls, but vega is not uniformly low in all scenarios. 
  • The impact of vega depends on the stock price’s position relative to the strike prices:
    • Near the long call’s strike price: The net vega is positive.
    • Between the strikes: The net vega may become positive or negative depending on the stock price.
    • Near the short call’s strike price: The net vega is negative.

Key Considerations and Risks

The bear put spread is a cost-effective, defined-risk strategy, but traders should be aware of its limitations and risks before entering.

  • Limited Profit Potential: The theoretical profit is capped because the short call limits gains, making this strategy less ideal for highly bullish markets. 
  • Upfront Cost (Premium Paid): The net premium paid is a theoretical maximum loss, meaning if the stock stays below the lower strike price, traders lose this entire amount.
  • Time Decay (Theta): Since options lose value over time, the spread may shrink if the stock price doesn't move, especially as expiration nears.
  • Impact of Volatility: Higher volatility usually helps by increasing option prices, while lower volatility can reduce the spread’s value.
  • Liquidity Concerns: If the options are not actively traded, buying or selling at a fair price may be difficult, increasing transaction costs.
  • Early Assignment Risk: The short call may be exercised early, especially near an ex-dividend date, which could lead to unexpected obligations.

Building a Bull Call Spread Strategy With Alpaca’s Trading API

This section provides a step-by-step guide to implementing a bull call spread using Python and Alpaca’s Trading API in the Paper Trading environment. The strategy is a two-legged, directional strategy that involves simultaneously buying long call and short call options. This approach helps manage risk and optimize decision-making by leveraging key metrics and filters. If you’re not connected to Alpaca’s API or are unfamiliar with options trading, here are some useful resources to help you follow along:

Important Notes:

  • The code assumes a flat interest rate, excludes dividends, and is not optimized for 0DTE options. It does not consider market hours and serves as a starting point rather than a complete strategy.
  • Expiration Day Order Rules:
    • Orders must be submitted before 3:15 p.m. ET for stocks/options and 3:30 p.m. ET for broad-based ETFs (e.g., SPY, QQQ).
    • Orders placed after these times will be rejected.
    • Expiring positions are auto-liquidated at 3:30 p.m. ET (stocks/options) and 3:45 p.m. ET (broad-based ETFs) for risk management.
  • Multi-Leg (MLeg) Orders:
  • American-Style Options Considerations:
    • American-style options may be assigned early, affecting trade outcomes. Profit/loss scenarios assume expiration without early exercise and do not factor in transaction fees (e.g., brokerage fees, commissions). Traders should also monitor ex-dividend dates for short calls to manage assignment risk.
  • Example equity:
    • "WMT" is used for demonstration purposes and should not be considered investment advice.

Step 1: Setting Up the Environment and Trade Parameters

The first step involves configuring the environment for trading a bull call spread and specifying your Alpaca Paper Trading API keys. This ensures your API connections and variable setup are secure and streamlined for data retrieval and trading in operations. 

Note: While we are using Jupyter Notebook for this tutorial, you can use Google Collab or any other IDE as your code environment. You can also find all of the below code on Alpaca’s Github page.

# Install or upgrade the package `alpaca-py` and import it
!python3 -m pip install --upgrade alpaca-py

import pandas as pd
import numpy as np
from scipy.stats import norm
import time
from scipy.optimize import brentq
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
import os
from typing import Any, Dict, List, Optional, Tuple

import alpaca
from alpaca.data.timeframe import TimeFrame, TimeFrameUnit
from alpaca.data.historical.option import OptionHistoricalDataClient
from alpaca.data.historical.stock import StockHistoricalDataClient, StockLatestTradeRequest
from alpaca.data.requests import StockBarsRequest, OptionLatestQuoteRequest, OptionSnapshotRequest
from alpaca.trading.client import TradingClient
from alpaca.trading.requests import (
    MarketOrderRequest,
    GetOptionContractsRequest,
    MarketOrderRequest,
    OptionLegRequest,
    ClosePositionRequest,
)
from alpaca.trading.enums import (
    AssetStatus,
    OrderSide,
    OrderClass,
    TimeInForce,
    ContractType,
)

The script initializes Alpaca clients for trading stocks and options, as well as for handling data requests.

# API credentials for Alpaca
API_KEY = "YOUR_ALPACA_API_KEY_FOR_PAPER_TRADING"
API_SECRET = 'YOUR_ALPACA_API_SECRET_KEY_FOR_PAPER_TRADING'
BASE_URL = None
## We use paper environment for this example (Please do not modify this. This example is for paper trading only)
PAPER = True

# Initialize Alpaca clients
trade_client = TradingClient(api_key=API_KEY, secret_key=API_SECRET, paper=PAPER, url_override=BASE_URL)
option_historical_data_client = OptionHistoricalDataClient(api_key=API_KEY, secret_key=API_SECRET, url_override=BASE_URL)
stock_data_client = StockHistoricalDataClient(api_key=API_KEY, secret_key=API_SECRET)

# Below are the variables for development this documents
# Please do not change these variables
trade_api_url = None
trade_api_wss = None
data_api_url = None
option_stream_data_wss = None

Step 2: Calculations for Implied Volatility and Option Greeks with Black-Scholes model

The calculate_implied_volatility function estimates implied volatility (IV) using the Black-Scholes model. The function defines a reasonable range for sigma (1e-6 to 5.0) to avoid extreme values. For deep OTM options where the price is close to intrinsic value, the function directly sets IV to 0.0 to prevent calculation errors. A numerical root-finding method is used to solve for IV, ensuring accuracy even in edge cases.

# Calculate implied volatility
def calculate_implied_volatility(option_price, S, K, T, r, option_type):
    
    # Define a reasonable range for sigma
    sigma_lower = 1e-6
    sigma_upper = 5.0  # Adjust upper limit if necessary

    # Check if the option is out-of-the-money and price is close to zero
    intrinsic_value = max(0, (S - K) if option_type == 'call' else (K - S))
    if option_price <= intrinsic_value + 1e-6:
        
        print('Option price is close to intrinsic value; implied volatility is near zero.')
        return 0.0
    
    # Define the function to find the root
    def option_price_diff(sigma):
        d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
        d2 = d1 - sigma * np.sqrt(T)
        if option_type == 'call':
            price = S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
        elif option_type == 'put':
            price = K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1)
        return price - option_price

    try:
        return brentq(option_price_diff, sigma_lower, sigma_upper)
    except ValueError as e:
        print(f"Failed to find implied volatility: {e}")
        return None

The calculate_greeks function estimates all option Greeks: delta, gamma, theta, and vega based on the implied volatility calculated through calculate_implied_volatility. In this demonstration, we mainly use delta and vega as well as implied volatility.

def calculate_greeks(option_price, strike_price, expiration, underlying_price, risk_free_rate, option_type):
    T = (expiration - pd.Timestamp.now()).days / 365 # It is unconventional, but some use 225 days (# of annual trading days) in replace of 365 days
    T = max(T, 1e-6)  # Set minimum T to avoid zero
    
    if T == 1e-6:
        print('Option has expired or is expiring now; setting Greeks based on intrinsic value.')
        if option_type == 'put':
            delta = -1.0 if underlying_price < strike_price else 0.0
        else:
            delta = 1.0 if underlying_price > strike_price else 0.0
        gamma = 0.0
        theta = 0.0
        vega = 0.0
        return delta, gamma, theta, vega
    
    # Calculate IV
    IV = calculate_implied_volatility(option_price, underlying_price, strike_price, T, risk_free_rate, option_type)

    if IV is None or IV == 0.0:
        print('Implied volatility could not be determined, skipping Greek calculations.')
        return None
    
    d1 = (np.log(underlying_price / strike_price) + (risk_free_rate + 0.5 * IV ** 2) * T) / (IV * np.sqrt(T))
    d2 = d1 - IV * np.sqrt(T) # d2 for Theta calculation
    # Calculate Delta
    delta = norm.cdf(d1) if option_type == 'call' else -norm.cdf(-d1)
    # Calculate Gamma
    gamma = norm.pdf(d1) / (underlying_price * IV * np.sqrt(T))
    # Calculate Vega
    vega = underlying_price * np.sqrt(T) * norm.pdf(d1) / 100
    # Calculate Theta
    if option_type == 'call':
        theta = (
            - (underlying_price * norm.pdf(d1) * IV) / (2 * np.sqrt(T))
            - (risk_free_rate * strike_price * np.exp(-risk_free_rate * T) * norm.cdf(d2))
        )
    else:
        theta = (
            - (underlying_price * norm.pdf(d1) * IV) / (2 * np.sqrt(T))
            + (risk_free_rate * strike_price * np.exp(-risk_free_rate * T) * norm.cdf(-d2))
        )
    # Convert annualized theta to daily theta
    theta /= 365
    
    return delta, gamma, theta, vega

Step 3: Pick Your Strategy Inputs

Underlying Price and Strike Range

The script retrieves the current WMT price via Alpaca’s market data, then calculates the minimum and maximum strike prices by multiplying that price by (1 - 0.06) and (1 + 0.06), respectively. This ensures that the strike range covers ±5% around the market price.

Expiration Dates

The code sets a minimum (today + 21 days) and maximum (today + 60 days) for potential expirations. However, we further refine it to a common range of 21 to 60 days. This means the final options that are picked for the long call and short call must fall within that 21–60 day window, which is a common range for short to medium-term directional strategies or event-driven trades.

Additionally, the script incorporates a risk-free rate of 1% (0.01) in internal calculations, enhancing the accuracy of implied volatility and Greek metrics, particularly vega and gamma. This helps fine-tune pricing models and ensures a more precise evaluation of the straddle’s risk and reward dynamics.

Liquidity and Open Interest Threshold

To avoid thinly traded contracts, the code sets an Open Interest (OI) threshold of 50. This ensures that only options with sufficient liquidity are considered, leading to tighter spreads and smoother execution.

Option Greeks and IV Ranges

The criteria dictionary defines acceptable ranges for both legs of the bull call spread. For example, each option could have an expiration between 21 and 60 days, an implied volatility (IV) between 0.20 and 0.50, a delta between 0.20 and 0.65, and a vega between 0.01 and 0.12. These ranges help target options that are reasonably priced and not too deep ITM or OTM.

Target Profit

For the purposes of this example, we chose a target profit percentage of 40%, meaning the spread will be closed if its value drops to 40% from the initial debit. Additionally, stop-loss thresholds are set with a delta limit of 80% and a vega limit of 40%, providing clear guidelines to exit the position if sensitivity to price or volatility moves beyond acceptable levels.

Buying Power Allocation

Only 5% of the account’s buying power (BUY_POWER_LIMIT = 0.05) is allocated to the trade. The code calculates the available buying power and ensures that the total cost of the spread remains within this conservative limit, helping maintain disciplined position sizing.

Evaluating the Risk

In order to calculate the theoretical maximum risk associated with a spread, we set buying_power_limit as a threshold to decide whether to enter the position or not. Traders can also set up alerts or automate exits when these thresholds are reached. Active monitoring or using limit orders is recommended to mitigate slippage in fast-moving markets.

Monitoring and Potential Adjustments

We also set several thresholds for rolling or rinsing an entered position. For this example, we apply these thresholds to short call options we sold to enter. The TARGET_PROFIT_PERCENTAGE is set to 0.4, meaning the spread will be closed if its value rises 40% from the initial profit from the short call option. In addition, a DELTA_STOP_LOSS of 0.80 and a VEGA_STOP_LOSS of 0.40 are used to trigger an exit if the option’s delta or vega exceed these levels, helping manage risk during rapid price or volatility changes. Traders can set up alerts or automate these exits to ensure that positions are actively managed in fast-moving markets.

Liquidity Considerations and Stop-Loss Limits

Because we set an open interest threshold (OI_THRESHOLD = 50), we’re already filtering out illiquid strikes. Still, be mindful that in a fast-moving market, option prices can gap, and a typical stop-loss order may trigger at less favorable prices. A common practice is often to monitor the position actively or use limit orders to exit, ensuring we don’t face excessive slippage.

# Select the underlying stock
underlying_symbol = 'WMT'

# Set the timezone
timezone = ZoneInfo('America/New_York')

# Get current date in US/Eastern timezone
today = datetime.now(timezone).date()

# Define a 6% range around the underlying price 
STRIKE_RANGE = 0.06

# Buying power percentage to use for the trade
BUY_POWER_LIMIT = 0.05

# Risk free rate for the options greeks and IV calculations
risk_free_rate = 0.01

# Check account buying power
buying_power = float(trade_client.get_account().buying_power)

# Set the open interest volume threshold
OI_THRESHOLD = 50

# Calculate the limit amount of buying power to use for the trade
buying_power_limit = buying_power * BUY_POWER_LIMIT

# Set the expiration date range for the options
min_expiration = today + timedelta(days=21)
max_expiration = today + timedelta(days=60)

# Get the latest price of the underlying stock
def get_underlying_price(symbol):
    # Get the latest trade for the underlying stock
    underlying_trade_request = StockLatestTradeRequest(symbol_or_symbols=symbol)
    underlying_trade_response = stock_data_client.get_stock_latest_trade(underlying_trade_request)
    return underlying_trade_response[symbol].price

# Get the latest price of the underlying stock
underlying_price = get_underlying_price(underlying_symbol)

# Set the minimum and maximum strike prices based on the underlying price
min_strike = str(underlying_price * (1 - STRIKE_RANGE))
max_strike = str(underlying_price * (1 + STRIKE_RANGE))

# Define the criteria for selecting the options
# Each key corresponds to a leg and maps to a tuple of: (expiration range, IV range, delta range, vega range)
criteria = {
    'short_call':  ((21, 60), (0.20, 0.50), (0.20, 0.65), (0.01, 0.12)),
    'long_call': ((21, 60), (0.20, 0.50), (0.20, 0.65), (0.01, 0.12))
}

# Set target profit levels
TARGET_PROFIT_PERCENTAGE = 0.4
DELTA_STOP_LOSS = 0.80
VEGA_STOP_LOSS = 0.40

# Display the values
print(f"Underlying Symbol: {underlying_symbol}")
print(f"{underlying_symbol} price: {underlying_price}")
print(f"Strike Range: {STRIKE_RANGE}")
print(f"Buying Power Limit Percentage: {BUY_POWER_LIMIT}")
print(f"Risk Free Rate: {risk_free_rate}")
print(f"Account Buying Power: {buying_power}")
print(f"Buying Power Limit: {buying_power_limit}")
print(f"Open Interest Threshold: {OI_THRESHOLD}")
print(f"Minimum Expiration Date: {min_expiration}")
print(f"Maximum Expiration Date: {max_expiration}")
print(f"Minimum Strike Price: {min_strike}")
print(f"Maximum Strike Price: {max_strike}")

Step 4: Market Data Analysis with Underlying Assets

When implementing a bull call spread, it's essential to identify market conditions that support the strategy. Using a combination of price trends, volatility measures, and technical indicators, traders would be able to optimize entry points and improve trade efficiency. The following factors may help the bull call spread setups based on underlying asset price movement and volatility events.

Monitoring historical price movements can help traders evaluate the broader trend of the underlying asset before entering a bull call spread. Reviewing candlestick charts offers valuable insights into recent price action, as well as key support and resistance levels. This analysis helps traders identify whether the asset is trending upward or trading within a range.

As mentioned, bull call spreads often perform well in moderately bullish environments, where the price is expected to rise gradually toward the short call’s strike price. It is less suitable for strongly bearish markets or highly volatile conditions, where sharp reversals could cause the spread to lose value before expiration.

# Get the historical data for the underlying stock by symbol and timeframe
# ref. https://alpaca.markets/sdks/python/api_reference/data/option/historical.html
def get_stock_data(underlying_symbol, days=90):
    today = datetime.now(timezone).date()
    req = StockBarsRequest(
        symbol_or_symbols=[underlying_symbol],
        timeframe=TimeFrame(amount=1, unit=TimeFrameUnit.Day),     # specify timeframe
        start=today - timedelta(days=days),                          # specify start datetime, default=the beginning of the current day.
    )
    return stock_data_client.get_stock_bars(req).df

# List of stock agg objects while dropping the symbol column
priceData = get_stock_data(underlying_symbol, days=180).reset_index(level='symbol', drop=True)

import plotly.graph_objects as go

# Bar chart for the stock price
fig = go.Figure(data=[go.Candlestick(x=priceData.index,
                open=priceData['open'],
                high=priceData['high'],
                low=priceData['low'],
                close=priceData['close'])])

fig.show()

This code returns the bar chart like in the image below.

Historical stock price analysis for "WMT", visualized as a candlestick chart using Alpaca's StockBarsRequest. Data spans from September 2024 to March 2025, showing open, high, low, and close prices.
Bar chart: historical stock price analysis for "WMT"

Bollinger Bands for Volatility Assessment

Bollinger Bands provide a dynamic range around the asset's price, helping traders assess current market volatility and potential price extremes. When the underlying stock price approaches the upper Bollinger Band, it may indicate overbought conditions, while a move toward the lower band could suggest oversold levels.

For a bull call spread, Bollinger Bands can help identify whether the asset is in a favorable moderate uptrend, which is ideal for this strategy. A price trending upward toward the middle to upper band — without excessive volatility — may signal conditions where the stock could rise gradually into the profitable zone between the long and short call strikes. In contrast, if the asset is near the lower band or moving erratically, the strategy’s chances of success may decline, as sharp reversals or unpredictable price swings increase the risk of losing the initial premium paid for the spread.

# setup bollinger band calculations
def check_bb(df, period=14, multiplier=2):
    bollinger_bands = []
    # Calculate the Simple Moving Average (SMA)
    df['SMA'] = df['close'].rolling(window=period).mean()
    # Calculate the rolling standard deviation
    df['StdDev'] = df['close'].rolling(window=period).std()
    # Calculate the Upper Bollinger Band (two standard deviation)
    df['Upper Band'] = df['SMA'] + (multiplier * df['StdDev'])
    # Calculate the Lower Bollinger Band (two standard deviation)
    df['Lower Band'] = df['SMA'] - (multiplier * df['StdDev'])
    # Get the most recent Upper Band value
    upper_bollinger_band = df['Upper Band'].iloc[-1]
    lower_bollinger_band = df['Lower Band'].iloc[-1]
    
    bollinger_bands = [upper_bollinger_band, lower_bollinger_band]
    return bollinger_bands

bollinger_bands = check_bb(priceData, 14, 2)

# The current market price is not too close to the two-standard deviation level yet but is relatively closer to the higher Bollinger Band.
print(f"Latest Upper Bollinger Band is: {bollinger_bands[0]}. Latest Lower Bollinger Band is {bollinger_bands[1]}; while underlying stock '{underlying_symbol}' price is {underlying_price}.")

Step 5: Set up for a bull call spread (Find both short and long call options)

This code sets up a tool for printing messages from the code. It tells Python to show general messages (INFO level) and more detailed messages (DEBUG level) so we can see what's happening and troubleshoot if needed.

# Configure logging
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

The get_options function retrieves active option contracts for a specified underlying symbol within a given strike price and expiration date range. Reminder to specify option_type as either ContractType.CALL or ContractType.PUT to filter for the desired option type.

# option_type: ContractType.CALL or ContractType.PUT.
def get_options(underlying_symbol, min_strike, max_strike, min_expiration, max_expiration, option_type):
    req = GetOptionContractsRequest(
        underlying_symbols=[underlying_symbol],
        status=AssetStatus.ACTIVE,
        type=option_type,
        strike_price_gte=min_strike,
        strike_price_lte=max_strike,
        expiration_date_gte=min_expiration,
        expiration_date_lte=max_expiration,        
    )
    return trade_client.get_option_contracts(req).option_contracts

The helper function validate_sufficient_OI verifies that an option's data includes the necessary fields and that its open interest exceeds a specified threshold. It is used at the start of the workflow to ensure that only eligible options are processed further.

def validate_sufficient_OI(option_data, OI_THRESHOLD):
    '''Ensure that the option has the required fields and sufficient open interest.'''
    if option_data.open_interest is None or option_data.open_interest_date is None:
        return False
    if float(option_data.open_interest) <= OI_THRESHOLD:
        return False
    return True

The calculate_option_metrics function calculates key metrics for an option contract. It starts by retrieving the latest quote for the option (using the option’s symbol) and computes the mid-price from the bid and ask prices. Then, it determines the option’s expiration date and the number of days remaining until expiration. Using these values, it calculates the implied volatility and the Greeks (delta, gamma, theta, and vega) by calling helper functions. Finally, it returns all these computed metrics as a dictionary for later use.

def calculate_option_metrics(option_data, underlying_price, risk_free_rate):
    """
    Calculate key option metrics including option price, implied volatility (IV), and option Greeks.
    """
    # Retrieve the latest quote for the option
    option_quote_req = OptionLatestQuoteRequest(symbol_or_symbols=option_data['symbol'])
    option_quote = option_historical_data_client.get_option_latest_quote(option_quote_req)[option_data['symbol']]
    option_price = (option_quote.bid_price + option_quote.ask_price) / 2

    # Calculate expiration and remaining days
    expiration_date = pd.Timestamp(option_data['expiration_date'])
    remaining_days = (expiration_date - pd.Timestamp.now()).days

    # Calculate implied volatility
    iv = calculate_implied_volatility(
        option_price=option_price,
        S=underlying_price,
        K=float(option_data['strike_price']),
        T=max(remaining_days / 365, 1e-6),
        r=risk_free_rate,
        option_type=option_data['type'].value
    )
    
    # Calculate Greeks (delta and vega)
    delta, gamma, theta, vega = calculate_greeks(
        option_price=option_price,
        strike_price=float(option_data['strike_price']),
        expiration=expiration_date,
        underlying_price=underlying_price,
        risk_free_rate=risk_free_rate,
        option_type=option_data['type'].value
    )
    
    return {
        'option_price': option_price,
        'expiration_date': expiration_date,
        'remaining_days': remaining_days,
        'iv': iv,
        'delta': delta,
        'gamma': gamma,
        'theta': theta,
        'vega': vega
    }

This function ensures that the provided option_data is in dictionary format. If option_data is given in a different format (e.g. OptionContract object), it converts it to a dictionary.

def ensure_dict(option_data):
    """
    Convert option_data to a dict using model_dump() if available (for Pydantic models),
    otherwise return the data as-is.
    """
    if hasattr(option_data, "model_dump"):
        return option_data.model_dump()
    return option_data

The build_option_dict function builds a comprehensive option dictionary by merging the raw option data with the calculated metrics from calculate_option_metrics. It first converts the input to a dictionary using ensure_dict, then obtains all necessary metrics (like option price, IV, delta, and vega). Finally, it constructs and returns a candidate dictionary that includes both the original option details (such as id, name, symbol, strike price, etc.) and the calculated metrics. This consolidated dictionary can be used for further processing or trading decisions.

def build_option_dict(option_data, underlying_price, risk_free_rate):
    """
    Build an option dictionary by merging raw option data with calculated metrics.
    """
    option_data = ensure_dict(option_data)  # Convert to dict if necessary
    metrics = calculate_option_metrics(option_data, underlying_price, risk_free_rate)
    candidate = {
        'id': option_data['id'],
        'name': option_data['name'],
        'symbol': option_data['symbol'],
        'strike_price': option_data['strike_price'],
        'root_symbol': option_data['root_symbol'],
        'underlying_symbol': option_data['underlying_symbol'],
        'underlying_asset_id': option_data['underlying_asset_id'],
        'close_price': option_data['close_price'],
        'close_price_date': option_data['close_price_date'],
        'expiration_date': metrics['expiration_date'],
        'remaining_days': metrics['remaining_days'],
        'open_interest': option_data['open_interest'],
        'open_interest_date': option_data['open_interest_date'],
        'size': option_data['size'],
        'status': option_data['status'],
        'style': option_data['style'],
        'tradable': option_data['tradable'],
        'type': option_data['type'],
        'initial_IV': metrics['iv'],
        'initial_delta': metrics['delta'],
        'initial_gamma': metrics['gamma'],
        'initila_theta': metrics['theta'],
        'initial_vega': metrics['vega'],
        'initial_option_price': metrics['option_price'],
    }

    return candidate

The function check_candidate_option_conditions checks if a candidate option meets the filtering criteria provided as a tuple (expiration range, IV range, delta range, vega range). It is used within the main workflow to determine whether a candidate qualifies for a specific leg of the bull call spread.

def check_candidate_option_conditions(candidate, criteria, label):
    """
    Check whether a candidate option meets the filtering criteria.
    The criteria is a tuple of (expiration_range, iv_range, delta_range, vega_range).
    Logs detailed information if a candidate fails a criterion.
    """
    expiration_range, iv_range, delta_range, vega_range = criteria

    if not (expiration_range[0] <= candidate['remaining_days'] <= expiration_range[1]):
        logger.debug(f"{candidate['symbol']} fails expiration condition for {label}: remaining_days {candidate['remaining_days']} not in {expiration_range}.")
        return False
    if not (iv_range[0] <= candidate['initial_IV'] <= iv_range[1]):
        logger.debug(f"{candidate['symbol']} fails IV condition for {label}: initial_IV {candidate['initial_IV']} not in {iv_range}.")
        return False
    if not (delta_range[0] <= candidate['initial_delta'] <= delta_range[1]):
        logger.debug(f"{candidate['symbol']} fails delta condition for {label}: initial_delta {candidate['initial_delta']} not in {delta_range}.")
        return False
    if not (vega_range[0] <= candidate['initial_vega'] <= vega_range[1]):
        logger.debug(f"{candidate['symbol']} fails vega condition for {label}: initial_vega {candidate['initial_vega']} not in {vega_range}.")
        return False

    return True

The pair_call_candidates function identifies a valid bull call spread by pairing a long call and short call with the same expiration date. It ensures the long call’s strike is below the underlying price and the short call’s strike is above it, matching the typical setup for this strategy. The first matching pair is returned, or None if no valid pair is found.

def pair_call_candidates(short_calls, long_calls, underlying_price):
    """
    For the bull call spread, require: long_call strike <= underlying_price < short_call strike.
    Returns the first valid pair found.
    """
    for sc in short_calls:
        for lc in long_calls:
            if sc['expiration_date'] == lc['expiration_date'] and lc['strike_price'] <= underlying_price < sc['strike_price']:
                logger.info(f"Selected Bull call spread: short_call {sc['symbol']} and long_call {lc['symbol']} with expiration {sc['expiration_date']}.")
                return sc, lc
    
    # If no valid pair is found, log the expiration date (if available) from the candidate lists.
    expiration_info = None
    if short_calls:
        expiration_info = short_calls[0]['expiration_date']
    elif long_calls:
        expiration_info = long_calls[0]['expiration_date']

    if expiration_info:
        logger.info(f"No valid bull call spread pair found for expiration {expiration_info} with the given candidates and underlying price conditions.")
    else:
        logger.info("No valid bull call spread pair found: no candidate data available.")
    
    return None, None

This check_buying_power function calculates the net cost (risk) of entering a bull call spread and ensures it does not exceed the buying power limit. It compares the difference between the long call’s price and the short call’s price, multiplied by the contract size. If the cost is too high, it raises an exception to prevent the trade.

def check_buying_power(short_call: Dict[str, Any], long_call: Dict[str, Any], buying_power_limit: float) -> None:
    """
    Calculates the total premium paid (risk) for a bull call spread and checks it against the buying power limit.
    If the buying power requirement is not met, the exception is thrown and the rest of the code is never executed.
    """
    option_size = float(short_call['size'])
    risk = (long_call['initial_option_price'] - short_call['initial_option_price']) * option_size
    logger.info(f"Calculated bull call spread risk: {risk}.")
    
    if risk >= buying_power_limit:
        raise Exception('Buying power limit exceeded for a bull call spread risk.')

The find_options_for_bull_call_spread function coordinates the process of finding and validating a bull call spread. It filters call options based on criteria, groups potential long and short candidates by expiration, and attempts to pair them into valid spreads. If a suitable pair is found, it checks the spread’s buying power requirement before returning the first valid pair. If no valid spread is found, it returns an empty list.

def find_options_for_bull_call_spread(call_options, underlying_price, risk_free_rate, buying_power_limit, criteria, OI_THRESHOLD):
    """
    Orchestrates the workflow to build a bull call spread.
    Groups options by expiration, filters them with criteria, pairs candidates using helper functions,
    and checks buying power.
    
    Returns:
        A list of legs [short_call, long_call] if a valid pair is found, or an empty list otherwise.
    """
    short_call_candidates_by_exp: Dict[pd.Timestamp, List[Dict[str, Any]]] = {}
    long_call_candidates_by_exp: Dict[pd.Timestamp, List[Dict[str, Any]]] = {}

    # Process each option candidate
    for option_data in call_options:
        if not validate_sufficient_OI(option_data, OI_THRESHOLD):
            logger.warning(f"Insufficient open interest for option {getattr(option_data, 'symbol', 'unknown')} (threshold: {OI_THRESHOLD}). Skipping candidate.")
            continue

        candidate = build_option_dict(option_data, underlying_price, risk_free_rate)
        expiration_date = candidate['expiration_date']
        short_call_candidates_by_exp.setdefault(expiration_date, [])
        long_call_candidates_by_exp.setdefault(expiration_date, [])
        
        # Check each candidate for both call criteria
        if check_candidate_option_conditions(candidate, criteria['short_call'], 'short_call'):
            short_call_candidates_by_exp[expiration_date].append(candidate)
            logger.info(f"Added {candidate['symbol']} as a short call candidate for expiration {expiration_date}.")
        if check_candidate_option_conditions(candidate, criteria['long_call'], 'long_call'):
            long_call_candidates_by_exp[expiration_date].append(candidate)
            logger.info(f"Added {candidate['symbol']} as a long call candidate for expiration {expiration_date}.")

    # Process only expiration dates common to both candidate groups
    common_expirations = set(short_call_candidates_by_exp.keys()) & set(long_call_candidates_by_exp.keys())
    for expiration_date in common_expirations:
        sc, lc = pair_call_candidates(short_call_candidates_by_exp[expiration_date],
                                      long_call_candidates_by_exp[expiration_date],
                                      underlying_price)
        if sc and lc:
            try:
                check_buying_power(sc, lc, buying_power_limit)
            except Exception as e:
                logger.error(f"Pair for expiration {expiration_date} failed buying power check: {e}")
                continue
            logger.info(f"Selected bull call spread for expiration {expiration_date}: short {sc['symbol']}, long {lc['symbol']}.")
            return [sc, lc]

    logger.info("No valid bull call spread found.")
    return [None, None]

We now can run the get_options function and the find_options_for_bull_call_spread function to find a reasonable set of options for a bull call spread.

call_options = get_options(underlying_symbol, min_strike, max_strike, min_expiration, max_expiration, ContractType.CALL)
sc, lc = find_options_for_bull_call_spread(call_options, underlying_price, risk_free_rate, buying_power_limit, criteria, OI_THRESHOLD)

Step 6: Execute A Bull Call Spread Strategy

The script below builds the list of options for the bull call spread and places an order.

def place_bull_call_spread_order(short_call, long_call):
    """
    Place a bull call spread order if both short_call and long_call data are provided.
    """
    if not (short_call and long_call):
        logger.info("No valid bull call spread found.")
        return None
    try:
        # Build order legs: sell the short call and buy the long call.
        order_legs = [
            OptionLegRequest(
                symbol=short_call['symbol'],
                side=OrderSide.SELL,
                ratio_qty=1
            ),
            OptionLegRequest(
                symbol=long_call['symbol'],
                side=OrderSide.BUY,
                ratio_qty=1
            )]
        # Create a market order for a multi-leg (spread) order.
        req = MarketOrderRequest(
            qty=1,
            order_class=OrderClass.MLEG,
            time_in_force=TimeInForce.DAY,
            legs=order_legs
        )
        res = trade_client.submit_order(req)
        logger.info("A bull call spread order placed successfully.")
        return res
    except Exception as e:
        logger.error(f"Failed to place a bull call spread order: {e}")
        return None

We then run the function as shown below.

res = place_bull_call_spread_order(sc, lc)
res

Before the order is filled, traders may choose to cancel it. Please note that individual legs cannot be canceled or replaced. We must cancel the entire order. If you are not familiar with the level 3 option trading with multi-legs, please check out an example code for multi-leg option trading on Alpaca’s Github page.

# Query by the order's id
q1 = trade_client.get_order_by_client_id(res.client_order_id)

# Replace overall order
if q1.status != OrderStatus.FILLED:
    # Cancel the whole order
    trade_client.cancel_order_by_id(res.id)
    print(f"Canceled order: {res}")

else:
    print("Order is already filled.")

Step 7: How to Adjust or Exit a Bull Call Spread (Rolling and Rinsing)

This function, roll_rinse_bull_call_spread, automates the exit (or roll) process for an existing bull call spread. It first retrieves the latest market data for the short call to calculate its current price, delta, and vega, and then determines whether exit conditions are met—based on a target profit percentage or if the option’s delta or vega exceed predetermined thresholds.

If any of these criteria are met, it closes both legs of the spread. Additionally, if rolling is enabled, it then searches for new option candidates and attempts to open a new bull call spread order. If the exit conditions are not met, the function simply logs that the position is being held.

def roll_rinse_bull_call_spread(short_call, long_call, rolling, target_profit_percentage, delta_stop_loss_thres, vega_stop_loss_thres, option_type, criteria, risk_free_rate, min_strike, max_strike, min_expiration, max_expiration, OI_THRESHOLD):
    """
    Checks if a bull call spread meets exit criteria (profit or stop-loss levels)
    based on current option price, delta, and vega. If the criteria are met,
    it closes the existing spread and, if rolling=True, attempts to open a new spread.
    Returns:
        Tuple containing a status message and new spread data if a new spread is opened, otherwise None.
    """
    underlying_symbol = short_call['underlying_symbol']
    underlying_price = get_underlying_price(underlying_symbol)
    
    # Retrieve current quote for the short call
    option_symbol = short_call['symbol']
    option_quote_request = OptionLatestQuoteRequest(symbol_or_symbols=option_symbol)
    option_quote = option_historical_data_client.get_option_latest_quote(option_quote_request)[option_symbol]
    current_short_price = (option_quote.bid_price + option_quote.ask_price) / 2
    
    metrics = calculate_option_metrics(short_call, underlying_price, risk_free_rate)
    current_delta = metrics['delta']
    current_vega = metrics['vega']
    
    # Determine target exit price based on the initial premium received for the short call.
    target_price = metrics['option_price'] * target_profit_percentage
    
    # Check exit criteria: either the short call price is at or below the target,
    # the absolute delta exceeds the threshold, or vega is above the threshold.
    if current_short_price <= target_price or abs(current_delta) >= delta_stop_loss_thres or current_vega >= vega_stop_loss_thres:
        logger.info(f"Exit criteria met for {underlying_symbol}: current_short_price={current_short_price}, "
                    f"target_price={target_price}, delta={current_delta}, vega={current_vega}.")
        # Execute the roll or rinse (exit) of the spread
        try:
            # Close the short call
            trade_client.close_position(
                symbol_or_asset_id=short_call['symbol'],
                close_options=ClosePositionRequest(qty='1')
            )
            logger.info(f"Closed short call: {short_call['symbol']}")
            
            # Close the long call
            trade_client.close_position(
                symbol_or_asset_id=long_call['symbol'],
                close_options=ClosePositionRequest(qty='1')
            )
            logger.info(f"Closed long call: {long_call['symbol']}")
        except Exception as e:
            msg = f"Failed to close existing bull call spread on {underlying_symbol}: {e}"
            logger.error(msg)
            return msg, None

        # If rolling, attempt to open a new bull call spread
        if rolling:
            try:
                # Find latest call options
                call_options = get_options(underlying_symbol, min_strike, max_strike, min_expiration, max_expiration, option_type)
                # Find new bull call spread candidates
                sc, lc = find_options_for_bull_call_spread(call_options, underlying_price, risk_free_rate, buying_power_limit, criteria, OI_THRESHOLD)
                # Place a new bull call spread order and return the response message
                res = place_bull_call_spread_order(sc, lc)
                if res:
                    new_spread = {
                        'short_call': sc,
                        'long_call': lc
                    }
                    return f"Rolled bull call spread on {underlying_symbol}. {res}", new_spread
                else:
                    msg = f"Failed to open new bull call spread on {underlying_symbol} after closing."
                    logger.error(msg)
                    return msg, None
            except Exception as e:
                msg = f"Failed to roll into a new bull call spread on {underlying_symbol}: {e}"
                logger.error(msg)
                return msg, None
        else:
            # If not rolling, simply exit the position
            return f"Closed (rinsed) bull call spread on {underlying_symbol}.", None
    else:
        # Criteria not met; hold the position.
        msg = (f"Holding bull call spread on {underlying_symbol}: current_short_price={current_short_price}, "
               f"target_price={target_price}, delta={current_delta}, vega={current_vega}.")
        logger.info(msg)
        return msg, None

The Importance of Backtesting A Bull Call Spread Strategy

Backtesting your options strategy is a critical step in developing a robust bull call spread strategy, especially when implementing it in an algorithmic trading system. Since a bull call spread’s profitability depends on moderate price appreciation and factors like time decay (theta) and implied volatility shifts (vega), backtesting helps traders evaluate how the spread would have performed under historical market conditions.

With Alpaca’s Trading API, we can not only backtest using an underlying asset’s historical data but also extract historical option data. The script below retrieves historical option data. However, accessing the latest OPRA data may require an 'Algo Trader Plus' subscription. Meanwhile, the free 'Basic' account typically provides the necessary historical data.

# Initialize the Option Historical Data Client
option_historical_data_client = OptionHistoricalDataClient(
    api_key=API_KEY, 
    secret_key=API_SECRET, 
    url_override=BASE_URL
)

# Define the request parameters
req = OptionBarsRequest(
    #  sc["symbol"] = the short call option
    symbol_or_symbols=sc["symbol"],
    timeframe=TimeFrame.Day,  # Choose timeframe (Minute, Hour, Day, etc.)
    start="2025-02-01",  # Start date
    end="2025-03-14"  # End date
)

option_historical_data_client.get_option_bars(req)

Trading A Bull Call Spread on Alpaca’s Dashboard

We can also execute a bull call spread using Alpaca’s dashboard manually.

Please note we are using WMT as an example and it should not be considered investment advice.

Step 1. Log in to your account

Sign in to your account and navigate to the “Home” page.

Step 2. Find your desired trade

On your dashboard in the top left, we can select between using your paper trading or live accounts. It’s important that we select the appropriate trading account before implementing an options strategy. If we want to practice without the risk of real capital, we can use your paper trading accounts. If we are looking to implement strategies, we can use your live account.

Once you’ve selected the desired account within your dashboard, we can monitor your portfolio, access market data, execute trades, and review your trading history. In order to create your first options trade, use the search bar to find and select the applicable option ticker symbol.

Alpaca Trading API dashboard Home page: Manage your paper trading account, view portfolio performance, and place stock orders
Alpaca Trading API dashboard Home page

Step 3. Check underlying security’s bar chart

If we search the desired underlying asset, a page shows the asset’s bar chart. We can use this bar chart for a simple analysis.

Alpaca Trading API dashboard: Check securities performance, and place orders on 'WMT' stock
Alpaca Trading API dashboard Trade page

Step 4. Opening an options position

Assume we have already analyzed the market and determined the strike price for the two legs as follows: long call option to be $96.00 and long call to be $98.00 at the expiration date of March 28, 2025. Once determined, select and review your order type (market or limit) and decide on the number of contracts you’d like to trade. We can select up to four legs but in the bull call spread we only need two. 

On the asset’s page, toggle from “Stocks” to “Options” and determine your desired contract type between calls and puts. We can also adjust the expiration date from the dropdown, ranging anywhere from 0DTE options to contracts hundreds of days in the future.

A bull call spread, for example, requires longing OTM call options and shorting ITM call options. Therefore, we can establish your position in the dashboard by selecting one long call option with "Buy To Open” and one short call option with “Sell To Open” to purchase the options contracts.

Please note: The order in which we select the options does not matter. Additionally, on Alpaca’s dashboard and Trading API, credits are displayed as negative values, while debits appear as positive values. For example, in the image below, a debit spread (where your initial entry requires payment) is shown with a positive limit price ($1.13 in this case) under 'Limit Price (Debit),' reflecting a total debit of $113.08 (with an extra 8 cents for the OCC Clearing Fee and Options Regulatory Fee).

Once selected, review your order type (such as market, limit, stop, stop limit, or trailing stop) and the number of contracts you’d like to trade. Click the “Confirm Order” button on the bottom right.

Alpaca Trading API dashboard: Check option chains and place multi-leg orders at various strike prices: long call at $96 and short call at $98 to build a bull call spread option strategy
Alpaca Trading API dashboard: Check option chains and place multi-leg orders

Your paper trading account will then mimic the execution of the order as though it were a real transaction.

Optional: Cancel your order

Keep in mind that with a multi-leg options order, we cannot cancel or replace an individual child leg. We can only cancel or replace the entire multi-leg options order. To do this, navigate to the 'Order' section, select the order we want to cancel, and click the 'Cancel 1 selected' button.

Alpaca Trading API Orders page: Cancel the selected order for the bull call spread
Alpaca Trading API Orders page: Cancel selected order

You’ll then be prompted to confirm.

Confirm Cancellation of the selected multi-legs order for a bull call spread postition
Confirm Cancellation

Step 5. Closing your position

Closing an options position involves selling the options contract that we previously bought (in a long position) or buying back the options contract that we previously sold (in a short position). Either action terminates, or “closes”, your associated position.

If you’d like to close your position, find the desired options contract on your “Home” page or under “Positions”. Click on the appropriate contract symbol and select “Liquidate 2 selected” to close your bull call spread position.

Alpaca Trading API Orders page: Close (liquidate) selected order for a bull call spread.
Alpaca Trading API Orders page: Close (liquidate) selected order

We will, then, click the “Confirm” button to liquidate the position.

Confirm multi-leg positions liquidations to close the bull call spread
Confirm multi-leg positions liquidations

Advantages and Disadvantages of Bull Call Spreads

Advantages of Bull Call Spreads

  • Defined Risk Exposure: One of the primary benefits of a bull call spread is the limitation on potential losses. The maximum theoretical loss is confined to the net premium paid to establish the spread, regardless of how much the underlying asset's price may decline. This characteristic provides traders with a controlled risk environment, allowing for more precise financial planning and risk management. ​
  • Cost Efficiency: Compared to purchasing a single call option, a bull call spread is generally more cost-effective. The premium received from selling the higher strike call option offsets part of the cost of buying the lower strike call. This reduction in initial outlay can make the strategy more accessible, especially for traders with limited capital or those seeking to optimize their investment efficiency. ​
  • Profit Potential in Moderately Bullish Scenarios: This strategy is particularly suited for situations where the trader anticipates a moderate increase in the underlying asset's price. It allows for the realization of gains up to a certain limit, aligning well with expectations of modest bullish movements. This targeted approach can be advantageous when the market is not expected to experience significant volatility. 

Disadvantages of Bull Call Spreads

  • Capped Profit Potential: One of the main downsides of a bull call spread is that profits are limited. Even if the underlying stock surges well beyond the higher strike price, your gains stop at the upper strike. This cap on upside can be frustrating if the stock rallies strongly.
  • Time Decay Risk: Both options in the spread lose value over time, meaning the position can decline even if the stock moves in the right direction, just not quickly enough. This sensitivity to the passage of time makes proper timing essential when using the strategy.
  • Dividend Assignment Risk: If the underlying stock pays dividends, there’s a chance the short call could be exercised early, especially around the ex-dividend date. This unexpected assignment can disrupt your plan and create additional obligations, such as delivering shares.

Conclusion

The bull call spread is a valuable strategy for traders seeking controlled risk and defined profit potential in moderately bullish markets. By combining a long call and short call with the same expiration, this spread lowers the upfront cost compared to buying a standalone call while still allowing participation in upward price movements.

As part of a diversified trading approach, the bull call spread can help balance risk and reward, but it requires careful consideration of several factors. Traders should be particularly aware of volatility levels, the impact of time decay (theta), and how the underlying asset’s price movement aligns with the chosen strike prices.

For traders using algorithmic strategies, the bull call spread can be an efficient way to express a moderately bullish view with built-in risk limits. However, as with any options strategy, successful automation requires robust risk management, real-time monitoring, and clear exit criteria. Traders should ensure that their algorithms account for changes in volatility, early assignment risks, and evolving market conditions to maintain long-term performance.

We hope you've found this tutorial on how to trade bull call spreads insightful and useful for getting started on your own strategy. As we put these concepts into practice, feel free to share your feedback and experiences on our forum, Slack community, or subreddit! And don’t forget to check out the rest of our options-related tutorials.

For those looking to integrate options trading with Alpaca’s Trading API, here are some additional resources:

FAQs

What are the downsides of a bull call spread?

The main downsides are the capped profit potential and the risk of losing the entire premium paid if the stock doesn’t rise enough. The strategy is also sensitive to time decay and volatility changes, which can work against the position.

What is the maximum risk of a bull call spread?

The theoretical maximum risk is the net premium paid to enter the spread. This is the initial cost of buying the long call minus the premium received from selling the short call.

What happens when a bull call spread expires in the money?

If both calls expire in-the-money, the spread reaches its maximum value, equal to the difference between the two strike prices. The trader’s profit is this spread value minus the initial premium paid.


Options trading is not suitable for all investors due to its inherent high risk, which can potentially result in significant losses. Please read Characteristics and Risks of Standardized Options before investing in options.

Past hypothetical backtest results do not guarantee future returns, and actual results may vary from the analysis.

The Paper Trading API is offered by AlpacaDB, Inc. and does not require real money or permit a user to transact in real securities in the market. Providing use of the Paper Trading API is not an offer or solicitation to buy or sell securities, securities derivative or futures products of any kind, or any type of trading or investment advice, recommendation or strategy, given or in any manner endorsed by AlpacaDB, Inc. or any AlpacaDB, Inc. affiliate and the information made available through the Paper Trading API is not an offer or solicitation of any kind in any jurisdiction where AlpacaDB, Inc. or any AlpacaDB, Inc. affiliate (collectively, “Alpaca”) is not authorized to do business.

Please note that this article is for general informational purposes only and is believed to be accurate as of the posting date but may be subject to change. The examples above are for illustrative purposes only.

All investments involve risk, and the past performance of a security, or financial product does not guarantee future results or returns. There is no guarantee that any investment strategy will achieve its objectives. Please note that diversification does not ensure a profit, or protect against loss. There is always the potential of losing money when you invest in securities, or other financial products. Investors should consider their investment objectives and risks carefully before investing.

Securities brokerage services are provided by Alpaca Securities LLC ("Alpaca Securities"), member FINRA/SIPC, a wholly-owned subsidiary of AlpacaDB, Inc. Technology and services are offered by AlpacaDB, Inc.

This is not an offer, solicitation of an offer, or advice to buy or sell securities or open a brokerage account in any jurisdiction where Alpaca Securities are not registered or licensed, as applicable.