Hello! Welcome back to our course on building a trading system.
In the previous lesson, we successfully implemented our first concrete strategy, the BullPutSpread class. We saw how to inherit from our abstract Strategy base class and encapsulate the entire logic for finding a bullish credit spread, from fetching options to pairing the final legs.
Today, we will leverage that foundation to build the mirror image of that strategy. The learning outcome is to implement a BearCallSpread strategy class inheriting from the abstract strategy base class. You'll see how the Strategy design pattern we've established makes adding new, related strategies incredibly efficient. We will follow a similar process, but adapt the logic for a bearish market view using call options.
1. Understanding the Bear Call Spread
Before we write any code, let's quickly review the Bear Call Spread. It's a credit spread strategy used when you have a neutral to bearish outlook on an underlying asset. The goal is for the asset's price to stay below the strike price of the call option you sell.
A bear call spread is constructed by:
- Selling a call option with a strike price (K1) that is out-of-the-money (above the current stock price). This generates a premium.
- Buying a call option with a higher strike price (K2) and the same expiration date. This serves as protection and defines the maximum loss.
The net result is a credit received, which is also the maximum potential profit.
Plot Options Payoff: Bull and Bear Spread Strategies
To solidify your understanding of the mechanics and payoff profile, please read the 'Bear Call Spread' section from the article 'Plot Options Payoff' on CodeArmo.
Focus on the description of the Bear Call Spread. Pay attention to how it's constructed (selling a lower strike call, buying a higher strike), the formulas for max profit and loss, and the accompanying payoff diagram. This will be the blueprint for our code's logic.
The payoff diagram from the article shows a limited profit and limited loss profile, which is exactly what we want to create with our code.

2. Structuring the BearCallSpread Class
Just like with BullPutSpread, our new class will inherit from the abstract Strategy class. This means it must provide a concrete implementation for any abstract methods defined in the parent, like find_trade_opportunity.
The concept of creating a specialized subclass that inherits common functionality but adds its own specific behavior is fundamental to object-oriented programming.
Python OOP Tutorial 4: Inheritance - Creating Subclasses
As a quick refresher on how to customize a subclass in Python, this video from Corey Schafer provides a clear, practical example. It will reinforce the concepts of overriding attributes and using super().__init__.
Please watch the segment from 06:06 to 08:45. Focus on how the Developer subclass is given its own __init__ method to handle an extra attribute (prog_lang) while still leveraging the parent class's __init__ method via super().
Now, let's define the skeleton for our BearCallSpread class. The __init__ method will look very similar to the one for the BullPutSpread, taking a symbol and a dictionary of parameters. The key difference will be the default delta ranges, which are now for call options.
# In framework/strategies.py
from .base import Strategy # Assuming Strategy ABC is in base.py
class BearCallSpread(Strategy):
"""
A concrete implementation of a Bear Call Spread strategy.
"""
def __init__(self, symbol: str, params: dict):
"""
Initializes the BearCallSpread strategy.
Args:
symbol (str): The underlying symbol for the strategy.
params (dict): A dictionary of strategy-specific parameters.
"""
super().__init__(name=f"BearCallSpread_{symbol}")
self.symbol = symbol
# Unpack parameters with default values
self.min_dte = params.get('min_dte', 21)
self.max_dte = params.get('max_dte', 60)
self.oi_threshold = params.get('oi_threshold', 50)
self.strike_range_pct = params.get('strike_range_pct', 0.06)
# Criteria for selecting the short and long call legs
# For OTM calls, delta is positive and ranges from 0 to 1.
self.criteria = params.get('criteria', {
'short_call': {'delta': (0.15, 0.40)}, # Delta range for the call we sell
'long_call': {'delta': (0.05, 0.20)} # Delta range for the call we buy
})
def find_trade_opportunity(self, data_handler, analytics_engine):
"""
Finds a suitable Bear Call Spread trade based on market data and criteria.
This method will contain the core logic for selecting the two call options.
"""
# ... Core logic to find the spread will go here ...
return None # Placeholder
Notice the new criteria for the deltas. For a bear call spread, we sell a call that is closer to the money (higher delta) and buy one that is further out-of-the-money (lower delta).
3. Implementing the Core Logic: find_trade_opportunity
This is where we implement the heart of the strategy. The workflow is almost identical to the BullPutSpread, which demonstrates the power of our framework's design. The main differences are:
- We fetch call options instead of puts.
- We use the delta criteria defined for calls.
- The pairing logic is reversed to satisfy the conditions of a bear call spread.
Here is the full implementation of the BearCallSpread class, including all the necessary helper methods adapted for this strategy. Pay close attention to the comments highlighting the key changes from the previous lesson.
# In framework/strategies.py (full class implementation)
class BearCallSpread(Strategy):
"""
A concrete implementation of a Bear Call Spread strategy.
"""
def __init__(self, symbol: str, params: dict):
super().__init__(name=f"BearCallSpread_{symbol}")
self.symbol = symbol
self.min_dte = params.get('min_dte', 21)
self.max_dte = params.get('max_dte', 60)
self.oi_threshold = params.get('oi_threshold', 50)
self.strike_range_pct = params.get('strike_range_pct', 0.10) # Wider range for calls
self.criteria = params.get('criteria', {
'short_call': {'delta': (0.15, 0.40)},
'long_call': {'delta': (0.05, 0.20)}
})
def find_trade_opportunity(self, data_handler, analytics_engine):
"""
Orchestrates the workflow to find a bear call spread.
"""
# 1. Get underlying price
underlying_price = data_handler.get_latest_price(self.symbol)
if underlying_price is None:
print(f"Could not fetch price for {self.symbol}")
return None
# 2. Get all available CALL options within DTE and strike range
# --- CHANGE: Fetch 'call' options ---
call_options = data_handler.get_option_chain(
underlying_symbol=self.symbol,
min_dte=self.min_dte,
max_dte=self.max_dte,
strike_range_pct=self.strike_range_pct,
option_type='call' # Key change here
)
# 3. Process options to find candidates
short_call_candidates = []
long_call_candidates = []
for option_contract in call_options:
if not self._validate_liquidity(option_contract, data_handler):
continue
option_with_analytics = analytics_engine.get_analytics(option_contract)
if option_with_analytics is None:
continue
if self._check_candidate_conditions(option_with_analytics, 'short_call'):
short_call_candidates.append(option_with_analytics)
if self._check_candidate_conditions(option_with_analytics, 'long_call'):
long_call_candidates.append(option_with_analytics)
# 4. Pair candidates to find a valid spread
selected_spread = self._pair_candidates(short_call_candidates, long_call_candidates, underlying_price)
if selected_spread:
print(f"Found valid Bear Call Spread for {self.symbol}:")
print(f" Short Call: {selected_spread['short_leg']['symbol']}")
print(f" Long Call: {selected_spread['long_leg']['symbol']}")
return selected_spread
print(f"No valid Bear Call Spread found for {self.symbol}")
return None
# --- Helper Methods ---
def _validate_liquidity(self, option_contract, data_handler) -> bool:
"""Checks if the option meets the open interest threshold."""
open_interest = data_handler.get_open_interest(option_contract)
return open_interest is not None and open_interest > self.oi_threshold
def _check_candidate_conditions(self, option_with_analytics: dict, leg_type: str) -> bool:
"""Check if a candidate option meets the filtering criteria for a given leg."""
delta_range = self.criteria[leg_type]['delta']
delta = option_with_analytics['greeks']['delta']
return delta_range[0] <= delta <= delta_range[1]
def _pair_candidates(self, short_calls: list, long_calls: list, underlying_price: float) -> dict or None:
"""
Pairs short and long call candidates to form a valid spread.
--- CHANGE: Logic for a Bear Call Spread ---
We require:
- Same expiration date.
- underlying_price < short_call_strike < long_call_strike
"""
for sc in short_calls:
for lc in long_calls:
# Must have same expiration
if sc['expiration_date'] != lc['expiration_date']:
continue
# Check strike price condition for a bear call spread
sc_strike = sc['strike_price']
lc_strike = lc['strike_price']
if (underlying_price < sc_strike < lc_strike):
# Found a valid pair
return {'short_leg': sc, 'long_leg': lc}
return None
Test your understanding!
In our _pair_candidates method, we are looking for the first valid pair we can find and returning it immediately. This is efficient, but may not be optimal.
Imagine you want to find the bear call spread that offers the widest possible "cushion"—that is, the greatest distance between the underlying price and the short call strike (short_call_strike - underlying_price).
How would you modify the _pair_candidates method to find the spread that maximizes this cushion, instead of just returning the first one it finds?
Show answer
Instead of returning immediately, you would need to iterate through all possible valid pairs and keep track of the one with the best "cushion" found so far.
Here's how the logic would change:
- Initialize a
best_spreadvariable toNoneand amax_cushionvariable to0(or a very small number). - Iterate through all
short_callsandlong_callswith nested loops, just as before. - Inside the loops, check for the validity of the pair (
same expirationandunderlying_price < sc_strike < lc_strike). - If a valid pair is found, calculate its cushion:
cushion = sc_strike - underlying_price. - Compare this
cushiontomax_cushion. If it's greater, updatemax_cushion = cushionand setbest_spreadto the current pair ({'short_leg': sc, 'long_leg': lc}). - After the loops complete, return
best_spread.
This change transforms the method from a simple "find-first" search to an optimization search, allowing the strategy to be more selective based on a specific criterion.
Conclusion
Excellent work! We have now successfully implemented the BearCallSpread class. By building upon the structure from the previous lesson, you've seen firsthand how a well-designed framework allows for rapid extension. We now have two distinct, interchangeable strategies ready to be deployed by our system.
Key Takeaways:
- The
BearCallSpreadclass was created by inheriting from the abstractStrategyclass, just like its bullish counterpart. - We adapted the
__init__method with parameters and delta criteria specific to a bearish call spread. - The core logic in
find_trade_opportunitywas easily adapted by changing the option type to 'call' and modifying the pairing logic tounderlying_price < short_call_strike < long_call_strike. - This process reinforces the power of the Strategy design pattern: the "context" (our main event loop) can use either
BullPutSpreadorBearCallSpreadwithout knowing the internal details of how they work, as long as they both provide afind_trade_opportunitymethod.
Preview of the Next Lesson:
In this lesson and the last, we've used delta ranges to filter for potential legs. In our next lesson, we will develop logic within each class to select appropriate strike prices based on delta and spread width. This will refine our selection process, allowing us to specify not just the delta of the short leg, but also the exact desired width of the spread (e.g., a $5 wide spread), making our strategy's risk profile even more precise.