Algo Trading in Python: Design Patterns for Automated Options Engines
An architectural deep-dive into state management, risk modeling, and broker-agnostic execution for automated premium harvesting.
Download the source code using the button at the end of this article!
In the world of Algo Trading, the distance between a theoretical strategy and a production-ready system is often measured in the robustness of its architecture. This article provides a deep-dive exploration into a professional-grade Quant Trading engine designed to automate the Options Wheel strategy. Rather than presenting a monolithic script, we will dissect a modular system built on the principles of separation of concerns and defensive engineering. You will learn how to bridge the gap between raw market data and systematic execution by leveraging Python façades, specialized data models, and an auditable logging framework.
By the end of this guide, you will understand the mechanics of:
The Orchestration Layer: How to wire a command-line interface to a complex wheel loop.
Broker Abstraction: Using the Mixin pattern and Façades to create a “signed” Alpaca client.
Quant Filtering & Scoring: Implementing a stateless logic pipeline that transforms raw option chains into ranked, risk-adjusted candidates.
Stateful Risk Management: Tracking aggregate exposure to ensure the bot operates strictly within a defined capital budget.
Whether you are a software engineer looking to enter the quant space or a trader seeking to formalize your manual process, this code-oriented walkthrough provides the blueprints for a scalable, maintainable trading infrastructure.
# file path: scripts/run_strategy.py
from pathlib import Path
from core.broker_client import BrokerClient
from core.execution import sell_puts, sell_calls
from core.state_manager import update_state, calculate_risk
from config.credentials import ALPACA_API_KEY, ALPACA_SECRET_KEY, IS_PAPER
from config.params import MAX_RISK
from logging.strategy_logger import StrategyLogger
from logging.logger_setup import setup_logger
from core.cli_args import parse_argsThe entry point pulls in exactly the collaborators it needs to wire the wheel loop together. Path is used to resolve local resources like the symbol list. BrokerClient is the broker abstraction (a Facade over Alpaca) that the run will construct using ALPACA_API_KEY, ALPACA_SECRET_KEY, and IS_PAPER from config.credentials so all market data and order submission stay centralized and consistently authenticated. The execution leg is delegated to sell_puts and sell_calls from core.execution; the entry point won’t price or filter options itself, it hands off once it has account context. That account context comes from update_state and calculate_risk in core.state_manager, which translate raw positions into per-symbol wheel state and produce the aggregate exposure needed to enforce MAX_RISK from config.params before any orders go out. StrategyLogger and setup_logger from logging are split so the global logger is configured once (levels, sinks) and the per-run StrategyLogger records structured events like current positions, filtered symbols, and the exact legs sold for an auditable trail. Finally, parse_args from core.cli_args is the CLI boundary that tells the run how to behave (flags like fresh start, log verbosity, whether to persist logs). This mirrors the same orchestration pattern shown in main: parse_args configures logging, BrokerClient is built from credentials, state_manager derives risk and state, and execution functions place trades, with StrategyLogger capturing the narrative; unlike sell_puts or BrokerClient, which implement inner pipeline logic, these imports exist to compose the data flow from CLI input to logging, to broker interaction, to strategy actions in a single run of options-wheel-main_cleaned.
# file path: scripts/run_strategy.py
def main():
args = parse_args()
strat_logger = StrategyLogger(enabled=args.strat_log)
logger = setup_logger(level=args.log_level, to_file=args.log_to_file)
strat_logger.set_fresh_start(args.fresh_start)
SYMBOLS_FILE = Path(__file__).parent.parent / “config” / “symbol_list.txt”
with open(SYMBOLS_FILE, ‘r’) as file:
SYMBOLS = [line.strip() for line in file.readlines()]
client = BrokerClient(api_key=ALPACA_API_KEY, secret_key=ALPACA_SECRET_KEY, paper=IS_PAPER)
if args.fresh_start:
logger.info(”Running in fresh start mode — liquidating all positions.”)
client.liquidate_all_positions()
allowed_symbols = SYMBOLS
buying_power = MAX_RISK
else:
positions = client.get_positions()
strat_logger.add_current_positions(positions)
current_risk = calculate_risk(positions)
states = update_state(positions)
strat_logger.add_state_dict(states)
for symbol, state in states.items():
if state[”type”] == “long_shares”:
sell_calls(client, symbol, state[”price”], state[”qty”], strat_logger)
allowed_symbols = list(set(SYMBOLS).difference(states.keys()))
buying_power = MAX_RISK - current_risk
strat_logger.set_buying_power(buying_power)
strat_logger.set_allowed_symbols(allowed_symbols)
logger.info(f”Current buying power is ${buying_power}”)
sell_puts(client, allowed_symbols, buying_power, strat_logger)
strat_logger.save()main is the command-line run entry that wires together configuration, logging, current account
# file path: core/cli_args.py
import argparseThe file brings in argparse so parse_args can build and validate the command-line interface that supplies runtime configuration to the trading engine, aligning with the architecture’s goal of keeping tunable parameters outside core logic and returning a normalized Namespace for main to consume. Unlike other places that import BrokerClient, execution helpers, or Alpaca enums to perform trading or parsing work, cli_args only needs argparse because its sole job is defining flags like fresh-start and logging options rather than touching market data, state, or order flow.
# file path: core/cli_args.py
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
“--fresh-start”,
action=”store_true”,
help=”Liquidate all positions before running”
)
parser.add_argument(
“--strat-log”,
action=”store_true”,
help=”Enable strategy JSON logging”
)
parser.add_argument(
“--log-level”,
default=”INFO”,
choices=[”DEBUG”, “INFO”, “WARNING”, “ERROR”, “CRITICAL”],
help=”Set logging level for consol/file logs”
)
parser.add_argument(
“--log-to-file”,
action=”store_true”,
help=”Write logs to file instead of just printing to stdout”
)
return parser.parse_args()parse_args collects the runtime switches the operator can pass when launching the trading engine and returns a validated namespace that main consumes to decide how to run the wheel loop. It registers four flags with argparse. The fresh_start flag is a boolean that, when present, tells main to start from a clean slate: main will log that mode, invoke BrokerClient.liquidate_all_positions, and then proceed with the full symbol list and MAX_RISK as buying power; otherwise, main pulls current positions from the broker, uses calculate_risk and update_state to understand exposure, potentially sells covered calls with sell_calls, and derives remaining buying power before hunting for puts. The strat_log flag toggles StrategyLogger; main constructs StrategyLogger with that enabled setting, which determines whether the JSON strategy log is produced as it records positions, state, filtered symbols, candidate options, and finally saves the entry. The log_level option is constrained to the standard levels, ensuring only supported severities are accepted; main passes that choice to setup_logger so console and optional file logs match the requested verbosity. The log_to_file flag instructs setup_logger to also configure file output, creating the logs directory if needed. In short, parse_args centralizes the CLI defaults and help, validates the small set of operational toggles, and hands main a normalized configuration that gates liquidation behavior, strategy logging, and logging verbosity/destination for the rest of the run.
# file path: core/state_manager.py
from .utils import parse_option_symbol
from alpaca.trading.enums import AssetClassstate_manager pulls in parse_option_symbol from utils so it can normalize any option symbols it sees in positions into the standardized tuple the rest of the system expects, which is essential for calculate_risk and update_state to aggregate exposure by underlying, option type, and strike without reimplementing parsing. It also imports AssetClass from alpaca.trading.enums so the state logic can classify and filter positions and balances by asset class consistently with what the broker abstraction returns, ensuring that only the relevant instruments (for example, options versus equities) are included in risk computations. Compared to the broader broker-facing modules that import TradingClient, data clients, and multiple Alpaca enums for order routing, state_manager keeps its dependency surface minimal: it doesn’t talk to the API, it just uses the shared parser and a single enum to interpret the data it’s given. And while other parts of the project may use ContractType or AssetStatus to decide what to request or trade, here AssetClass is enough to gate which holdings flow into the state snapshot that drives the wheel loop.
# file path: core/state_manager.py
def calculate_risk(positions):
risk = 0
for p in positions:
if p.asset_class == AssetClass.US_EQUITY:
risk += float(p.avg_entry_price) * abs(int(p.qty))
elif p.asset_class == AssetClass.US_OPTION:
_, option_type, strike_price = parse_option_symbol(p.symbol)
if option_type == ‘P’:
risk += 100 * strike_price * abs(int(p.qty))
return riskcalculate_risk turns a raw list of open positions into a single “how much capital is on the line right now?” number that the main loop can compare against configured risk limits before placing new orders. It iterates over the positions the BrokerClient previously pulled from Alpaca and branches on the asset class. For US_EQUITY positions, it treats risk as committed capital, multiplying the average entry price by the absolute share count and adding that to the running total. For US_OPTION positions, it first calls parse_option_symbol to normalize the OCC symbol into its parts; remember parse_option_symbol gives us the option type and the strike as a dollar value. If the option type is a put, it
# file path: core/utils.py
import re
import pytz
from datetime import datetimeThe imports set up exactly what these utilities need to normalize symbols and time for the options-wheel flow. The regular expression library powers the parsing logic that splits an option symbol into its contract pieces so the contract model and risk checks can work with consistent fields. The timezone library provides the New York market timezone object, and the datetime module supplies the current time so timestamps can be generated in market time. You’ve already seen get_ny_timestamp rely on the timezone library and datetime to produce an ISO-formatted New York timestamp; other modules typically import get_ny_timestamp directly rather than pulling in timezone handling themselves, which keeps time normalization centralized. In contrast, this utilities module brings in the raw building blocks because it defines the timestamp helper and the symbol parser at the source. The other import sets you’ve seen, which include things like Path, json, typing, dataclasses, and BrokerClient, focus on file I/O, serialization, type modeling, or broker access; none of that is needed here because this standalone helper layer is limited to string parsing and time generation that feed risk calculations, state updates, and strategy logging across options-wheel-main_cleaned.
# file path: core/utils.py
def parse_option_symbol(symbol):
match = re.match(r’^([A-Za-z]+)(\d{6})([PC])(\d{8})$’, symbol)
if match:
underlying = match.group(1)
option_type = match.group(3)
strike_raw = match.group(4)
strike_price = int(strike_raw) / 1000.0
return underlying, option_type, strike_price
else:
raise ValueError(f”Invalid option symbol format: {symbol}”)parse_option_symbol is the small, reliable gatekeeper that turns a raw OCC-style option symbol coming from Alpaca positions into the normalized pieces the rest of the system expects. It validates that the symbol matches the standard structure of letters for the underlying, a six-digit expiration date, a single flag for call or put, and an eight-digit strike encoding. On a valid match, it extracts three components: the underlying ticker, the option_type (P or C), and a human-scale strike_price by decoding the eight-digit strike into dollars by dividing by one thousand. The expiration portion is recognized but not returned here because the immediate consumers that call parse_option_symbol only need type and strike for their calculations.
The control flow is straightforward: if the symbol conforms to the expected format, the function returns the tuple; otherwise, it raises a ValueError that includes the offending symbol. That guard ensures upstream logic never computes risk or state from malformed symbols.
Data-wise, this utility sits at the boundary between broker-reported strings and our normalized contract semantics. calculate_risk feeds each position’s symbol into parse_option_symbol and uses the returned option_type and strike_price to include only put exposure in the risk total by scaling the strike to the contract’s notional. update_state also relies on this same normalization when it needs to associate option positions back to their underlying and track wheel progression alongside equity holdings. Compared to score_options, which scores already-normalized option objects, parse_option_symbol is the lower-level parser used specifically when we only have a raw symbol string; its job is to make symbol handling consistent so risk, state, and strategy logging all interpret contracts identically in service of the options wheel loop.
# file path: logging/logger_setup.py
import logging
import sys
from pathlib import PathThese imports bring in exactly what the project’s centralized logging needs to fan logs from every subsystem into a consistent diagnostic stream. The logging
# file path: logging/logger_setup.py
def setup_logger(log_file=”logs/run.log”, level=”INFO”, to_file=False):
logger = logging.getLogger(”strategy”)
logger.setLevel(getattr(logging, level.upper()))
if not logger.handlers:
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(getattr(logging, level.upper()))
ch.setFormatter(logging.Formatter(”[%(message)s]”))
logger.addHandler(ch)
if to_file:
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
fh = logging.FileHandler(log_file)
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
“%(asctime)s - %(name)s - %(levelname)s - %(message)s”
))
logger.addHandler(fh)
return loggersetup_logger is the single entry point that brings the project’s logging online so the trading loop, broker abstraction, and strategy steps all speak with one consistent voice. main calls it right after parsing command-line flags, passing the desired verbosity and whether logs should be persisted to disk. It targets the logger named “strategy,” which acts as the root of a hierarchy; other modules that obtain child loggers like strategy.something automatically inherit this configuration, so the entire system shares the same handlers and formatting.
The function first acquires the “strategy” logger and sets its level from the provided string. It then follows a guarded initialization
# file path: logging/strategy_logger.py
from pathlib import Path
from datetime import datetime
from core.utils import get_ny_timestamp
import jsonPath and json are here to persist structured strategy events to disk as a single, auditable JSON record that sell_puts, sell_calls, and main can append to during a run. datetime pairs with get_ny_timestamp so every entry carries a New York–normalized timestamp from the shared utility you’ve already seen, with datetime available for any minor formatting the logger needs. Unlike the similar imports that pull in BrokerClient, typing, or dataclasses, or the pattern that grabs a standard logging.getLogger, this standalone logger deliberately keeps only filesystem, time, and JSON primitives and routes everything through StrategyLogger’s file-based, structured logging to keep the options-wheel record consistent across runs.
# file path: logging/strategy_logger.py
class StrategyLogger:
def __init__(self, enabled=True, log_path=”logs/strategy_log.json”):
self.enabled = enabled
self.log_file = Path(log_path)
self.log_entry = {}
if self.enabled:
self.log_file.parent.mkdir(parents=True, exist_ok=True)
self.log_entry[”datetime”] = get_ny_timestamp()
def set_fresh_start(self, is_fresh_start: bool):
if self.enabled:
self.log_entry[”fresh_start”] = is_fresh_start
self.log_entry[”current_positions”] = []
def add_current_positions(self, positions: list):
if self.enabled and not self.log_entry.get(”fresh_start”):
self.log_entry[”current_positions”] = [
{
“asset_class”: pos.asset_class.title().lower(),
“symbol”: pos.symbol,
“side”: pos.side.title().lower(),
“qty”: pos.qty,
“purchase_price”: pos.avg_entry_price,
“current_price”: pos.current_price,
“pnl”: pos.unrealized_pl
}
for pos in positions
]
def add_state_dict(self, state_dict: dict):
if self.enabled:
self.log_entry[”state_dict”] = state_dict
def set_buying_power(self, buying_power: float):
if self.enabled:
self.log_entry[”buying_power”] = buying_power
def set_allowed_symbols(self, symbols: list):
if self.enabled:
self.log_entry[”allowed_symbols”] = symbols
def set_filtered_symbols(self, symbols: list):
if self.enabled:
self.log_entry[”filtered_symbols”] = symbols
def log_call_options(self, call_options: list[dict]):
if self.enabled:
self.log_entry[”call_options”] = call_options
def log_put_options(self, put_options: list[dict]):
if self.enabled:
self.log_entry[”put_options”] = put_options
def log_sold_calls(self, call_dict: dict):
if self.enabled:
if self.log_entry.get(”sold_calls”) is None:
self.log_entry[”sold_calls”] = []
self.log_entry[”sold_calls”].append(call_dict)
def log_sold_puts(self, put_dict: dict):
if self.enabled:
if self.log_entry.get(”sold_puts”) is None:
self.log_entry[”sold_puts”] = []
self.log_entry[”sold_puts”].append(put_dict)
def save(self):
if not self.enabled:
return
if self.log_file.exists():
with open(self.log_file, “r”) as f:
try:
data = json.load(f)
if not isinstance(data, list):
raise ValueError(”Log file does not contain a list.”)
except json.JSONDecodeError:
data = []
else:
data = []
data.append(self.log_entry)
with open(self.log_file, “w”) as f:
json.dump(data, f, indent=2)StrategyLogger is the run-level ledger for the options wheel: it collects a single, structured record of what the strategy saw, decided, and executed, all stamped in New York time so decisions can be audited against the market clock. main creates a StrategyLogger based on CLI flags, then populates it as the loop proceeds; sell_puts and sell_calls add their own artifacts as they evaluate and place trades. When a StrategyLogger is constructed, it can be toggled off entirely; if enabled, it ensures the log directory exists and seeds a new log_entry with a New York–normalized timestamp via get_ny_timestamp so every saved record anchors to the trading session’s local market time. main immediately calls set_fresh_start, which marks whether the run will liquidate before acting; that flag also initializes current_positions and deliberately gates add_current_positions so we never snapshot holdings in a run that’s about to wipe them. When not in fresh
# file path: core/utils.py
def get_ny_timestamp():
ny_tz = pytz.timezone(”America/New_York”)
ny_time = datetime.now(ny_tz)
return ny_time.isoformat()get_ny_timestamp is the time anchor for the whole options-wheel flow: it produces a single, canonical “now” in New York market time so every timestamp written by the system lines up with the exchange’s clock. It does this by resolving the America/New_York timezone, creating a timezone-aware current datetime, and returning it as an ISO 8601 string. That normalization matters because all downstream consumers—risk checks, contract state, and audit logs—need to agree on market-time boundaries, including daylight saving transitions, when interpreting events like fills, expirations, and end-of-day risk. You can see it in action immediately when StrategyLogger is constructed in main; the logger stamps its log entry’s datetime using get_ny_timestamp, and that value flows into the JSON file written by StrategyLogger.save so every strategy run is auditable in exchange-local time. Contract.load_from_json also pulls from this helper so any contract objects brought back from disk are reconciled against the same New York clock the broker uses, which keeps snapshot ordering and subsequent updates consistent. You’ll notice several modules import datetime directly for other purposes but reach for get_ny_timestamp whenever they need a persisted or logged “now”; that pattern ensures symbol parsing and time normalization—the two responsibilities of this utility module—remain uniform across Contract and StrategyLogger without each caller re-implementing timezone handling.
# file path: core/execution.py
import logging
from .strategy import filter_underlying, filter_options, score_options, select_options
from models.contract import Contract
import numpy as npThese imports equip sell_puts and sell_calls to run the full screening-to-order bridge the wheel loop needs. Pulling in filter_underlying, filter_options, score_options, and select_options from the local strategy module gives this execution layer the complete selection pipeline without coupling it to raw configuration constants; unlike the example that imports DELTA_MIN and friends directly, the thresholds live inside strategy so execution just asks for ranked candidates. Contract from models.contract is used once a candidate is chosen so the order is wrapped in the normalized contract model that standardizes option details and New York market time, matching how parse_option_symbol and the broader contract model keep symbols/time consistent across the system. Importing logging lets this module create
# file path: core/execution.py
def sell_puts(client, allowed_symbols, buying_power, strat_logger = None):
if not allowed_symbols or buying_power <= 0:
return
logger.info(”Searching for put options...”)
filtered_symbols = filter_underlying(client, allowed_symbols, buying_power)
strat_logger.set_filtered_symbols(filtered_symbols)
if len(filtered_symbols) == 0:
logger.info(”No symbols found with sufficient buying power.”)
return
option_contracts = client.get_options_contracts(filtered_symbols, ‘put’)
snapshots = client.get_option_snapshot([c.symbol for c in option_contracts])
put_options = filter_options([Contract.from_contract_snapshot(contract, snapshots.get(contract.symbol, None)) for contract in option_contracts if snapshots.get(contract.symbol, None)])
if strat_logger:
strat_logger.log_put_options([p.to_dict() for p in put_options])
if put_options:
logger.info(”Scoring put options...”)
scores = score_options(put_options)
put_options = select_options(put_options, scores)
for p in put_options:
buying_power -= 100 * p.strike
if buying_power < 0:
break
logger.info(f”Selling put: {p.symbol}”)
client.market_sell(p.symbol)
if strat_logger:
strat_logger.log_sold_puts([p.to_dict()])
else:
logger.info(”No put options found with sufficient delta and open interest.”)sell_puts is the wheel’s short-put entry point that main calls after it has computed buying_power from calculate_risk and decided which underlyings are eligible this cycle. It first guards against empty inputs or zero capital and, if it can proceed, announces the scan. The first pass is at the stock level: filter_underlying asks BrokerClient.get_stock_latest_trade for the latest prices and keeps only symbols where 100 shares fit within the provided buying_power, which en
# file path: core/strategy.py
from config.params import DELTA_MIN, DELTA_MAX, YIELD_MIN, YIELD_MAX, OPEN_INTEREST_MIN, SCORE_MINThis import pulls the tunable selection thresholds from config.params into the strategy so the option-selection helpers can enforce the project’s policy without hardcoding numbers. DELTA_MIN and DELTA_MAX bound moneyness when filter_options screens the chain that BrokerClient delivers, YIELD_MIN and YIELD_MAX gate annualized return so we stay within configured yield targets, OPEN_INTEREST_MIN enforces liquidity, and SCORE_MIN becomes the floor that score_options and then select_options apply before handing candidates off to sell_puts or sell_calls. Unlike a direct assignment such as an inline OPEN_INTEREST_MIN set to 100, bringing these values in from config.params keeps the whole loop—underlying fetch, chain filter, scoring, and final selection—aligned with a single source of truth the operator can adjust, which is exactly how configuration is separated from logic in options-wheel-main_cleaned. We already looked at how imports elsewhere wire up logging and normalization; this one is specifically about binding the strategy’s filtering and scoring to the configured guardrails that control risk, return, and liquidity across the selection flow.
# file path: core/strategy.py
def filter_underlying(client, symbols, buying_power_limit):
resp = client.get_stock_latest_trade(symbols)
filtered_symbols = [symbol for symbol in resp if 100*resp[symbol].price <= buying_power_limit]
return filtered_symbolsfilter_underlying is the first affordability gate in the option-selection flow. Given a BrokerClient, a list of candidate tickers, and a buying_power_limit that has already been constrained by the overall risk budgeting we discussed with calculate_risk, it asks the broker abstraction for the latest trade on each symbol, then keeps only those underlyings where the cost of a single options lot—one hundred shares—fits within the available buying power. The call into BrokerClient.get_stock_latest_trade routes through our centralized API facade to the signed stock data client, ensuring we get a consistent user agent and credentials while pulling near-real-time prices. With those prices in hand, the function’s filtering step is straightforward: if one contract would be cash-secured at today’s trade price, the symbol stays; otherwise it’s dropped. The result is a pruned list of underlyings that we can actually afford to sell puts on right now.
In terms of data flow, sell_puts hands in the allowed_symbols and current buying_power, filter_underlying fetches their latest prices from the broker and returns only those symbols that pass the affordability test, and then sell_puts records that set via StrategyLogger.set_filtered_symbols before moving on to request put chains and snapshots. Compared to later steps like filter_options, score_options, and select_options, which evaluate individual contracts by greeks, yield, and an aggregate score, filter_underlying deliberately operates one level up at the underlying symbol layer. It narrows the universe before we pay the cost of fetching and scoring option chains, and it enforces the wheel’s core constraint that cash-secured puts must be backed by enough buying power to cover 100 shares at the current price.
# file path: core/broker_client.py
from config.params import EXPIRATION_MIN, EXPIRATION_MAX
from .user_agent_mixin import UserAgentMixin
from alpaca.trading.client import TradingClient
from alpaca.data.historical.option import OptionHistoricalDataClient
from alpaca.data.historical.stock import StockHistoricalDataClient, StockLatestTradeRequest
from alpaca.data.requests import OptionSnapshotRequest
from alpaca.trading.requests import GetOptionContractsRequest, MarketOrderRequest
from alpaca.trading.enums import ContractType, AssetStatus, AssetClass
from datetime import timedelta
from zoneinfo import ZoneInfo
import datetimeThese imports set up the Alpaca-facing façade that BrokerClient exposes to the strategies. EXPIRATION_MIN and EXPIRATION_MAX come from configuration so get_options_contracts can bound contract discovery to the tunable window the wheel uses. UserAgentMixin is the small mixin we layer onto Alpaca SDK clients to enforce a consistent User-Agent and credential handling; it’s the same pattern used in OptionHistoricalDataClientSigned, and it underpins the TradingClient and the historical data clients we compose into BrokerClient. The Alpaca trading and data classes supply the concrete clients and the request/enumeration types BrokerClient needs: TradingClient drives orders and positions; OptionHistoricalDataClient and StockHistoricalDataClient provide historical and snapshot data; OptionSnapshotRequest and StockLatestTradeRequest are the request builders BrokerClient uses in its snapshot and latest-trade helpers; GetOptionContractsRequest constructs the filter for contract searches, while MarketOrderRequest carries sell orders out to the broker; ContractType, AssetStatus, and AssetClass give strongly typed filters and descriptors for option/asset queries. Finally, datetime, timedelta, and ZoneInfo let BrokerClient compute a New York–aligned “today” and derive the expiration range from configuration, keeping the broker queries consistent with the project’s NY-market time normalization we’ve leaned on elsewhere.
# file path: core/broker_client.py
class BrokerClient:
def __init__(self, api_key, secret_key, paper=True):
self.trade_client = TradingClientSigned(api_key=api_key, secret_key=secret_key, paper=paper)
self.stock_client = StockHistoricalDataClientSigned(api_key=api_key, secret_key=secret_key)
self.option_client = OptionHistoricalDataClientSigned(api_key=api_key, secret_key=secret_key)
def get_positions(self):
return self.trade_client.get_all_positions()
def market_sell(self, symbol, qty=1):
req = MarketOrderRequest(
symbol=symbol, qty=qty, side=’sell’, type=’market’, time_in_force=’day’
)
self.trade_client.submit_order(req)
def get_option_snapshot(self, symbol):
if isinstance(symbol, str):
req = OptionSnapshotRequest(symbol_or_symbols=symbol)
return self.option_client.get_option_snapshot(req)
elif isinstance(symbol, list):
all_results = {}
for i in range(0, len(symbol), 100):
batch = symbol[i:i+100]
req = OptionSnapshotRequest(symbol_or_symbols=batch)
result = self.option_client.get_option_snapshot(req)
all_results.update(result)
return all_results
else:
raise ValueError(”Input must be a string or list of strings representing symbols.”)
def get_stock_latest_trade(self, symbol):
req = StockLatestTradeRequest(symbol_or_symbols=symbol)
return self.stock_client.get_stock_latest_trade(req)
def get_options_contracts(self, underlying_symbols, contract_type=None):
timezone = ZoneInfo(”America/New_York”)
today = datetime.datetime.now(timezone).date()
min_expiration = today + timedelta(days=EXPIRATION_MIN)
max_expiration = today + timedelta(days=EXPIRATION_MAX)
contract_type = {’put’: ContractType.PUT, ‘call’: ContractType.CALL}.get(contract_type, None)
req = GetOptionContractsRequest(
underlying_symbols=underlying_symbols,
status=AssetStatus.ACTIVE,
expiration_date_gte=min_expiration,
expiration_date_lte=max_expiration,
type=contract_type,
limit=1000,
)
all_contracts = []
page_token = None
while True:
if page_token:
req.page_token = page_token
response = self.trade_client.get_option_contracts(req)
all_contracts.extend(response.option_contracts)
page_token = getattr(response, “next_page_token”, None)
if not page_token:
break
return all_contracts
def liquidate_all_positions(self):
positions = self.get_positions()
to_liquidate = []
for p in positions:
if p.asset_class == AssetClass.US_OPTION:
self.trade_client.close_position(p.symbol)
else:
to_liquidate.append(p)
for p in to_liquidate:
self.trade_client.close_position(p.symbol)BrokerClient is the strategies’ single doorway into Alpaca, wrapping the signed TradingClientSigned, StockHistoricalDataClientSigned, and OptionHistoricalDataClientSigned so every market-data read and order write shares credentials and a consistent User-Agent. When main constructs a BrokerClient and hands it to the loop, the Repository pattern shows up clearly: get_positions supplies the raw positions list that calculate_risk and update_state consume to decide whether the wheel can proceed; market_sell turns a symbol and quantity into a day-time-in-force market order and submits it through the trading client; get_option_snapshot fetches a live snapshot for either one option symbol or a list, batching lists in groups of 100 to respect API limits and merging the per-batch results, with a guard that rejects non-string, non-list inputs; get_stock_latest_trade builds a latest-trade request for one or many underlyings and returns a mapping the filter_underlying step uses to screen symbols by buying power, as you saw when it checked 100-share blocks against the budget; get_options_contracts is the contract discovery workhorse, computing a New York–based date window from EXPIRATION_MIN and EXPIRATION_MAX, translating the optional human-friendly contract_type into the Alpaca enum, and paginating through GetOptionContractsRequest results until no next-page token remains, returning a flat list of active contracts for the specified underlyings; liquidate_all_positions drives a two-phase close-out by first immediately closing any US_OPTION positions, then closing all remaining positions, which covers equities. Data-wise, strategies pull from these methods in sequence: sell_puts asks get_stock_latest_trade via filter_underlying to narrow the universe to symbols the account can afford, then calls get_options_contracts for puts; sell_calls asks get_options_contracts for calls and wraps those raw contracts with Contract.from_contract so later filters and scoring can run. Remember calculate_risk uses the positions returned by get_positions (and parse_option_symbol inside that flow) to keep the loop inside configured limits before any call to market_sell or other order submission. The result is a centralized gateway that hides vendor-specific request shapes behind stable method names and feeds the options-wheel-main_cleaned pipeline with exactly the market data and execution hooks it needs.
# file path: models/contract.py
from typing import Optional
from dataclasses import dataclass, field
import datetime
from core.broker_client import BrokerClient
from core.utils import get_ny_timestamp
import jsonThese imports set up the Contract model as a lightweight domain object that can be enriched by the broker layer without pulling in vendor SDKs. Optional from typing marks fields like delta, prices, and open interest as unset until BrokerClient fills them in. dataclass and field define Contract as a value object with clear defaults, while keeping the BrokerClient reference out of comparisons and display so the model stays focused on contract attributes. datetime backs simple date arithmetic for things like days-to-expiration. BrokerClient is imported so a Contract can optionally hold a client and call back into it to resolve or validate a symbol and fetch snapshots when needed, which ties the model into the BrokerClient abstraction used throughout the wheel loop. get_ny_timestamp comes from core.utils so any timestamps the Contract records are normalized to New York market time, consistent with how the rest of the system treats time. json is here so a Contract can be serialized for logs or passed through the sell_puts and sell_calls flows in a broker-agnostic payload.
Compared to the broker-side imports that pull Alpaca clients, request types, and enums, these imports stay at the abstraction boundary by depending only on BrokerClient rather than the SDKs directly. And similar to other utility modules that pair datetime with get_ny_timestamp and json, this model uses those to keep time normalization and serialization consistent across the pipeline without bringing in file or logging concerns.
# file path: models/contract.py
@dataclass
class Contract:
underlying: str
symbol: str
contract_type: str
dte: Optional[float] = None
strike: Optional[float] = None
delta: Optional[float] = None
bid_price: Optional[float] = None
ask_price: Optional[float] = None
last_price: Optional[float] = None
oi: Optional[int] = None
underlying_price: Optional[float] = None
client: Optional[”BrokerClient”] = field(default=None, repr=False, compare=False)
def __post_init__(self):
if self.client:
self.update()
@classmethod
def from_contract(cls, contract, client=None) -> “Contract”:
return cls(
underlying = contract.underlying_symbol,
symbol = contract.symbol,
contract_type = contract.type.title().lower(),
oi = float(contract.open_interest) if contract.open_interest is not None else None,
dte = (contract.expiration_date - datetime.date.today()).days,
strike = contract.strike_price,
client = client
)
@classmethod
def from_contract_snapshot(cls, contract, snapshot) -> “Contract”:
if not snapshot:
raise ValueError(f”Snapshot data is required to create a Contract from a snapshot for symbol {contract.symbol}.”)
return cls(
underlying = contract.underlying_symbol,
symbol = contract.symbol,
contract_type = contract.type.title().lower(),
oi = float(contract.open_interest) if contract.open_interest is not None else None,
dte = (contract.expiration_date - datetime.date.today()).days,
strike = contract.strike_price,
delta = snapshot.greeks.delta if hasattr(snapshot, ‘greeks’) and snapshot.greeks else None,
bid_price = snapshot.latest_quote.bid_price if hasattr(snapshot, ‘latest_quote’) and snapshot.latest_quote else None,
ask_price = snapshot.latest_quote.ask_price if hasattr(snapshot, ‘latest_quote’) and snapshot.latest_quote else None,
last_price = snapshot.latest_trade.price if hasattr(snapshot, ‘latest_trade’) and snapshot.latest_trade else None
)
@classmethod
def from_dict(cls, data: dict) -> “Contract”:
return cls(**data)
def update(self):
if not self.client:
raise ValueError(”Cannot update Contract without a client.”)
snapshot = self.client.get_option_snapshot(self.symbol)
if snapshot and self.symbol in snapshot:
data = snapshot[self.symbol]
if hasattr(data, ‘greeks’) and data.greeks:
self.delta = data.greeks.delta
if hasattr(data, ‘latest_quote’) and data.latest_quote:
self.bid_price = data.latest_quote.bid_price
self.ask_price = data.latest_quote.ask_price
self.last_price = getattr(data.latest_trade, “price”, None)
if hasattr(data, ‘latest_trade’) and data.latest_trade:
self.last_price = data.latest_trade.price
def to_dict(self):
return {
“underlying”: self.underlying,
“symbol”: self.symbol,
“contract_type”: self.contract_type,
“dte”: self.dte,
“strike”: self.strike,
“delta”: self.delta,
“bid_price”: self.bid_price,
“ask_price”: self.ask_price,
“last_price”: self.last_price,
“oi”: self.oi,
“underlying_price”: self.underlying_price,
}
@staticmethod
def save_to_json(contracts: list[”Contract”], filepath: str):
payload = {
“timestamp”: get_ny_timestamp(),
“contracts”: [c.to_dict() for c in contracts]
}
with open(filepath, “w”) as f:
json.dump(payload, f, indent=2)
@staticmethod
def load_from_json(filepath: str):
with open(filepath, “r”) as f:
payload = json.load(f)
return [Contract.from_dict(d) for d in payload[”contracts”]]Contract is the project’s lightweight option model that the strategies and BrokerClient pass around when deciding what to sell and when persisting state. It holds the normalized identity of an option (underlying, vendor symbol, contract_type) plus the key selection fields the strategies use (dte, strike, delta, bid_price, ask_price, last_price, oi, and an optional underlying_price placeholder). When a BrokerClient is attached, Contract can hydrate its live market fields from Alpaca snapshots so downstream filters and scoring functions operate on current greeks and quotes rather than static metadata.
The dataclass fields establish the domain surface that sell_puts and sell_calls expect. underlying and symbol identify the contract across the system; contract_type is normalized for policy checks; dte is computed as days between the contract’s expiration and today so strategy math that gates expirations is comparable across vendors; strike, delta, bid/ask/last, and open interest are the core signals the filters read; underlying_price is present for strategies that annotate contracts with the stock’s latest trade.
Initialization includes an opt-in live refresh. If a Contract is constructed with a client, post_init immediately calls update. That call asks BrokerClient for an option snapshot by symbol and, when data is present, pulls greeks for delta and the latest quote and trade for bid, ask, and last price. This is the main handoff from the broker façade we covered earlier into the strategy layer: Contract requests a single symbol’s snapshot through BrokerClient.get_option_snapshot and then writes the relevant fields into the instance so selection helpers like filter_options, score_options, and select_options can work with up-to-date inputs without touching the SDK types directly. If no client is present, update is guarded to fail fast, which keeps construction of offline
# file path: core/broker_client.py
class TradingClientSigned(UserAgentMixin, TradingClient):
passTradingClientSigned is the trading-side counterpart to the signed data clients you saw earlier: it layers UserAgentMixin on top of Alpaca’s TradingClient so every order and account call made through BrokerClient carries the same authenticated headers and the project’s User-Agent. When main builds a BrokerClient, it instantiates TradingClientSigned with the API keys and paper/live flag; from that point forward, any strategy action that needs to read positions or place orders routes through BrokerClient to this class. For example, when liquidate_all_positions runs, it uses the inherited TradingClient methods to fetch positions and submit market sell orders, and UserAgentMixin._get_default_headers quietly ensures those requests include credentials and the standardized User-Agent. This mirrors the pattern used by StockHistoricalDataClientSigned and OptionHistoricalDataClientSigned, giving the project a uniform, signed façade across trading and data. The end result is that strategies like sell_puts and sell_calls can focus on selection logic while BrokerClient relies on TradingClientSigned to execute trades reliably under the same header policy as all market-data reads.
# file path: core/broker_client.py
class StockHistoricalDataClientSigned(UserAgentMixin, StockHistoricalDataClient):
passStockHistoricalDataClientSigned is the signed, user-identified entry point for all equity price lookups that strategies perform through BrokerClient. It inherits behavior from the Alpaca StockHistoricalDataClient but layers in UserAgentMixin so every request it makes builds its default headers by combining the SDK’s auth headers with the project’s USER_AGENT. That mixin is the key to the “signed” part here: it guarantees credentials and a consistent User-Agent are always present without each call site needing to remember them. BrokerClient wires this up at construction time and exposes convenience methods like get_stock_latest_trade, which in turn delegate to StockHistoricalDataClientSigned. When sell_puts kicks off, filter_underlying asks BrokerClient for the latest trades on a batch of symbols; those calls flow through this client and return the most recent prints that the affordability gate uses to keep the
# file path: core/strategy.py
def filter_options(options, min_strike = 0):
filtered_contracts = [contract for contract in options
if contract.delta
and abs(contract.delta) > DELTA_MIN
and abs(contract.delta) < DELTA_MAX
and (contract.bid_price / contract.strike) * (365 / (contract.dte + 1)) > YIELD_MIN
and (contract.bid_price / contract.strike) * (365 / (contract.dte + 1)) < YIELD_MAX
and contract.oi
and contract.oi > OPEN_INTEREST_MIN
and contract.strike >= min_strike]
return filtered_contractsfilter_options is the second gate in the selection pipeline after filter_underlying: once BrokerClient has supplied a list of Contract objects for a symbol’s option chain, this function pares that list down to only those contracts that meet our policy thresholds from config.params. It enforces a symmetric risk screen for both puts and calls by requiring a non-null delta whose absolute value sits between DELTA_MIN and DELTA_MAX, which keeps us in the “probable-to-expire-out-of-the-money”
# file path: core/strategy.py
def score_options(options):
scores = [(1 - abs(p.delta)) * (250 / (p.dte + 5)) * (p.bid_price / p.strike) for p in options]
return scoresscore_options converts a list of candidate Contract objects into a parallel list of numeric utilities so the strategy can rank choices before select_options enforces per-underlying uniqueness. Each score is the product of three intuitions aligned with the wheel’s goals: safety, time decay, and income per notional. The safety term uses one minus the absolute delta, so deeper out-of-the-money contracts (lower risk of assignment) score higher, and because it’s absolute it works the same for both puts and calls used by sell_puts and sell_calls. The time component scales roughly with trading days as 250 divided by days-to-expiry plus a small offset, which favors nearer expirations to capitalize on faster theta without exploding for ultra-short DTE. The income term divides the bid premium by strike
# file path: core/strategy.py
def select_options(options, scores, n=None):
filtered = [(option, score) for option, score in zip(options, scores) if score > SCORE_MIN]
best_per_underlying = {}
for option, score in filtered:
underlying = option.underlying
if (underlying not in best_per_underlying) or (score > best_per_underlying[underlying][1]):
best_per_underlying[underlying] = (option, score)
sorted_best = sorted(best_per_underlying.values(), key=lambda x: x[1], reverse=True)
return [option for option, _ in sorted_best[:n]] if n else [option for option, _ in sorted_best]select_options is the final gate that turns scored candidates into the specific contracts we will attempt to sell. By the time it runs inside sell_puts, we’ve already constrained affordability with filter_underlying, fetched contracts from BrokerClient.get_options_contracts, and removed anything that violates delta, yield, or liquidity policy via filter_options. score_options then produces a desirability score for each surviving Contract. select_options takes that list of contracts alongside their scores and applies two decisions: first, it pairs each contract with its score and drops any whose score falls below SCORE_MIN from the configuration, so we don’t promote marginal ideas. Second, it enforces diversification by keeping only the top-scoring contract per underlying symbol. It does this by scanning the filtered pairs and, for each underlying, retaining the pair with the highest score. The resulting one-per-underlying set is then sorted by score from best to worst, and the function returns the contracts in that order. If a limit n is provided, it returns only the top n; otherwise it returns all per-underlying winners. The output
# file path: core/execution.py
def sell_calls(client, symbol, purchase_price, stock_qty, strat_logger = None):
if stock_qty < 100:
msg = f”Not enough shares of {symbol} to cover short calls! Only {stock_qty} shares are held and at least 100 are needed!”
logger.error(msg)
raise ValueError(msg)
logger.info(f”Searching for call options on {symbol}...”)
call_options = filter_options([Contract.from_contract(option, client) for option in client.get_options_contracts([symbol], ‘call’)], purchase_price)
if strat_logger:
strat_logger.log_call_options([c.to_dict() for c in call_options])
if call_options:
scores = score_options(call_options)
contract = call_options[np.argmax(scores)]
logger.info(f”Selling call option: {contract.symbol}”)
client.market_sell(contract.symbol)
if strat_logger:
strat_logger.log_sold_calls(contract.to_dict())
else:
logger.info(f”No viable call options found for {symbol}”)sell_calls is the covered-call executor that turns an existing long stock position into premium by routing a single short call through BrokerClient. It starts by enforcing coverage: if stock_qty is less than one round lot, it raises a ValueError so we never write naked calls. With that guard satisfied, it asks BrokerClient for the call-option universe on the given symbol and immediately normalizes each vendor contract into our Contract model via Contract.from_contract; that step enriches the lightweight domain object with current greeks and quotes while keeping everything in our standardized shape. The resulting Contracts
# file path: scripts/run_strategy.py
if __name__ == “__main__”:
main()The standard module entrypoint guard hands control to main only when this file is executed as a script, which is exactly how we launch a strategy run from the command line. That call bridges the CLI into the strategy pipeline: main parses arguments, initializes structured logging through StrategyLogger and setup_logger, constructs a BrokerClient with the configured credentials, and then orchestrates the wheel loop. Depending on the fresh_start flag, main either liquidates and proceeds with the full symbol set and max risk, or it loads current positions, records them in StrategyLogger, derives state, writes covered calls via sell_calls for any long shares, and computes
# file path: core/execution.py
logger = logging.getLogger(f”strategy.{__name__}”)logger is initialized by requesting a namespaced logger from the standard logging system using the name strategy.. That name anchors all runtime messages from this file under the strategy hierarchy so they inherit whatever handlers and levels main configures via setup_logger, and they can be filtered or grouped with other strategy logs. In practice, sell_puts and the already-covered sell_calls emit their operational events through logger—things like screening outcomes, order submissions, and broker responses—while StrategyLogger continues to capture the structured, strategy-specific telemetry. Compared with the logger variable created inside main, which configures the logging backend and carries top-level run messages, this module-level logger concentrates logs for these execution entry points and flows them to the same handlers, aligning with the project’s logging discipline without duplicating configuration.
# file path: core/user_agent_mixin.py
USER_AGENT = “OPTIONS-WHEEL”USER_AGENT defines the single, canonical label our application will present in the HTTP User-Agent header. In this module that value is the source of truth the UserAgentMixin uses when assembling request headers: _get_default_headers pulls authentication from _get_auth_headers and then adds the user agent from USER_AGENT so every signed request carries the same identity. That behavior is mixed into concrete clients such as OptionHistoricalDataClientSigned, which inherits UserAgentMixin to gain the standardized header without duplicating code, a straightforward use of the mixin pattern to share cross-cutting request metadata across Trading, StockHistorical, and OptionHistorical clients. Compared with load_dotenv called elsewhere, which populates credentials and configuration from the environment, USER_AGENT is not configuration but a fixed identifier that flows into outbound Alpaca calls, letting the broker abstraction consistently tag all market data and trading requests initiated by the options-wheel-main_cleaned strategy.
# file path: core/user_agent_mixin.py
class UserAgentMixin:
def _get_default_headers(self) -> dict:
headers = self._get_auth_headers()
headers[”User-Agent”] = USER_AGENT
return headersUserAgentMixin is a small cross-cutting utility that ensures every signed Alpaca client we use identifies itself consistently. When any of our signed clients needs headers for an outbound request, _get_default_headers runs and first delegates to _get_auth_headers, which is provided by the underlying Alpaca client to assemble the standard authentication fields tied to the API key and secret. It then adds a User-Agent entry using the USER_AGENT constant that names our app, and returns that combined dictionary. Because TradingClientSigned, StockHistoricalDataClientSigned, and OptionHistoricalDataClientSigned inherit from UserAgentMixin before their respective Alpaca SDK classes, the method resolution order makes this version of _get_default_headers the one that gets used, while still relying on the SDK’s _get_auth_headers to avoid duplicating auth logic. In terms of data flow, BrokerClient constructs those three signed clients during initialization, so every network call they make—whether gathering positions ahead of sell_calls, pulling quotes that feed into filter_options, score_options, and select_options, or executing trades such as in liquidate_all_positions—will carry the same User-Agent header. That keeps trading and market data traffic branded uniformly across the API surface, which supports consistent logging and vendor-side attribution as the options-wheel-main_cleaned loop drives decisions and orders through Alpaca.
# file path: config/credentials.py
from dotenv import load_dotenv
import osThe module brings in load_dotenv from dotenv and the standard os module so it can materialize secrets and endpoints from a .env file into the process environment and then read them as environment variables. load_dotenv is invoked with override enabled so the .env values become the authoritative source for a run, which aligns with keeping credentials outside the repo while allowing per‑environment switching without touching strategy logic. os then provides getenv to pull the Alpaca API key, secret, and environment flags into module‑level constants; those values are what the broker abstraction uses to authenticate both trading and market‑data requests, completing the flow from .env to process environment to BrokerClient initialization.
# file path: config/credentials.py
load_dotenv(override=True)Calling load_dotenv with override enabled is the initialization step that pulls confidential keys and endpoints out of the local .env file and injects them into the process environment so this secrets module can act as the single authoritative source for authentication. By allowing the .env values to overwrite any preexisting environment variables, it guarantees that subsequent lookups via os.getenv—like the assignments to ALPACA_API_KEY and ALPACA_SECRET_KEY—resolve to the intended credentials defined for this run. That, in turn, is what the broker layer reads to authenticate both trading and market‑data requests, which the rest of the pipeline relies on indirectly: once the entrypoint hands control to the strategy, BrokerClient initializes with these credentials, fetches chains and account state, and only then do functions like filter_options, score_options, select_options, and sell_calls do their work. The earlier imports of dotenv and os simply make this environment‑loading and retrieval possible.
# file path: config/credentials.py
ALPACA_API_KEY = os.getenv(”ALPACA_API_KEY”)ALPACA_API_KEY is initialized by calling os.getenv to pull the public Alpaca credential from the process environment, which—because load_dotenv was invoked just before—typically comes from the .env file for local runs. In this project’s architecture, this module serves as the single authoritative source for broker authentication details, so BrokerClient can import ALPACA_API_KEY and use it to sign both trading and market‑data requests that drive the wheel loop (for example, when it fetches option chains that later flow through filter_options, score_options, and select_options, or when sell_calls routes an order). The closely related ALPACA_SECRET_KEY line does the same for the secret half of the credential pair, while IS_PAPER differs by applying a default and normalizing a boolean flag; load_dotenv is the precursor that seeds these environment variables so retrieval here is consistent.
# file path: config/credentials.py
ALPACA_SECRET_KEY = os.getenv(”ALPACA_SECRET_KEY”)ALPACA_SECRET_KEY is populated by asking os.getenv for the Alpaca secret value from the process environment, which, thanks to the earlier load_dotenv call, will include anything defined in a local .env file. This mirrors the nearby assignment of ALPACA_API_KEY but captures the confidential counterpart rather than the public key; both are centralized here so the broker abstraction can authenticate every trading and market‑data request against the correct Alpaca environment without strategy modules touching credentials. With these credentials in place, BrokerClient can establish authenticated sessions to fetch option chains and place orders, enabling the downstream pipeline you’ve already seen—filter_options, score_options, select_options, and ultimately sell_calls—to operate on real account state and live market data.
# file path: config/credentials.py
IS_PAPER = os.getenv(”IS_PAPER”, “true”).lower() == “true”IS_PAPER derives a boolean from the environment to decide whether we talk to Alpaca’s paper or live endpoints. After load_dotenv has pulled values from the .env file, it reads the IS_PAPER variable with a default that favors paper mode, normalizes the text to lowercase, and sets the flag true only when the value spells true. That yields a single, reliable toggle the broker layer consults when building its authenticated session and choosing base URLs, so all trading and market‑data calls occur in the intended environment without strategy code needing to care. Compared to ALPACA_API_KEY and ALPACA_SECRET_KEY, which simply retrieve strings, IS_PAPER adds normalization and a safe default so a missing or oddly cased setting still results in paper trading.
# file path: config/params.py
MAX_RISK = 80_000MAX_RISK sets the portfolio-level dollar budget the wheel is allowed to put at risk in a run, giving the rest of the system a single ceiling to respect when turning account state and market data into trades. It lives alongside the other knobs in config.params so modules that need it can read a consistent value without touching credentials; for example, filter_options, which already pulls thresholds from config.params, can exclude candidates whose cash-secured requirement or notional exposure would exceed the remaining headroom defined by MAX_RISK once current positions from BrokerClient are considered. After score_options and select_options have identified the best contracts, the execution path uses MAX_RISK as a final guard to decide how many contracts to route, or to skip an order entirely if adding it would push total exposure above the budget; sell_calls follows the same policy when sizing covered calls against owned shares and available buying power. Compared with DELTA_MAX, YIELD_MAX, and EXPIRATION_MAX—which shape the desirability of each individual contract—MAX_RISK governs aggregate allocation across the whole loop, tying strategy selection and order execution back to a unified risk cap. When the strategy is launched via the standard entrypoint guard, this value is already in place so every pass through the loop applies the same risk boundary.
# file path: config/params.py
DELTA_MIN = 0.15DELTA_MIN sets the lower bound on acceptable option delta for candidates the wheel will consider, anchoring how aggressively out-of-the-money we’re willing to sell for both short puts on entry and covered calls on exit. BrokerClient first supplies Contract objects with Greeks, including delta; when filter_options applies policy from this parameters module, DELTA_MIN acts as a guard so ultra-low-delta contracts that pay very little premium or have negligible assignment probability are dropped before any scoring. It works in tandem with DELTA_MAX to define a target delta band that reflects our risk/return stance for the automated loop, while YIELD_MIN separately enforces that the premium relative to underlying price isn’t too small and SCORE_MIN later provides a scoring floor once score_options has produced utilities. Centralizing DELTA_MIN here means the selection pipeline—filter_options, then score_options, then select_options—uses a consistent delta floor without touching credentials, keeping the options-wheel-main_cleaned run aligned with the intended balance between premium capture and assignment risk.
# file path: config/params.py
DELTA_MAX = 0.30DELTA_MAX is configured at 0.30 and serves as the upper bound of the delta window the strategy will accept when picking option contracts. Together with DELTA_MIN, which we already covered as the lower bound, it defines a target delta band that guides how far out-of-the-money we sell on short puts for entry and covered calls for exit. In the data flow, market data from the broker abstraction provides deltas for candidate contracts; the strategy selection layer reads DELTA_MIN and DELTA_MAX from this settings file and filters or ranks contracts to stay within that probability-of-money and premium tradeoff. This complements YIELD_MAX, which applies a separate cap on premium yield rather than moneyness, and it differs from MAX_RISK, which is a portfolio-level budget rather than a per-contract selection gate. Centralizing DELTA_MAX here ensures every trading loop iteration uses the same aggressiveness boundary when turning account state and live option greeks into executable orders.
# file path: config/params.py
YIELD_MIN = 0.04YIELD_MIN sets the floor on the option premium-to-collateral economics that a candidate must meet for the wheel to consider selling it. As the loop pulls option quotes through the broker abstraction and normalizes them in the contract model, the strategy selection step computes a yield metric for each short put or covered call candidate. If that computed yield falls below YIELD_MIN, the candidate is discarded even if other gates like DELTA_MIN are satisfied, ensuring the system only spends risk budget on trades that clear a minimum payoff threshold. Paired with YIELD_MAX, it defines an acceptable yield band so downstream scoring and execution read consistent, centralized economics constraints from this configuration module without touching credentials.
# file path: config/params.py
YIELD_MAX = 1.00YIELD_MAX caps the per-trade yield the strategy is willing to accept, expressed as a fraction of collateral at risk, so downstream selection and execution don’t chase outliers that look lucrative but are usually artifacts of illiquidity or bad quotes. When the broker abstraction feeds live option quotes into the strategy selector, the selector computes yield for each candidate and keeps only those whose yield falls between YIELD_MIN and YIELD_MAX; YIELD_MAX at 1.00 draws the top boundary of that window, pairing with YIELD_MIN to form a sanity band the wheel must stay within before sizing and order placement proceed. Unlike MAX_RISK, which constrains total portfolio exposure, YIELD_MAX operates at the single-candidate level alongside DELTA_MIN and DELTA_MAX to balance price edge with probability of profit: candidates must satisfy both the delta band and the yield band to advance through the pipeline toward execution.
# file path: config/params.py
EXPIRATION_MIN = 0EXPIRATION_MIN sets the floor, in days to expiration, for which option contracts the wheel will even consider when screening candidates. With a value of 0, the strategy allows same‑day expirations to pass the initial time‑horizon gate, which fits the project’s goal of harvesting short‑dated premium while keeping all such decisions centralized in this configuration. After the broker abstraction supplies option chains and the contract model normalizes timestamps to New York time, the strategy selector computes DTE per contract and applies this floor before any ranking—so nothing is excluded purely for being too near‑dated. It works in tandem with EXPIRATION_MAX, which caps the window at 21 days, defining a 0–21 DTE search band, while OPEN_INTEREST_MIN and SCORE_MIN apply orthogonal liquidity and quality thresholds within that band. Together with DELTA_MIN and the portfolio ceiling from MAX_RISK, this parameter shapes which expirations the wheel can trade for both short‑put entries and covered‑call exits, and ensures execution only targets expirations that align with the configured time horizon.
# file path: config/params.py
EXPIRATION_MAX = 21EXPIRATION_MAX caps the acceptable days-to-expiration window the wheel will consider to 21, which keeps the strategy focused on short-dated contracts consistent with the wheel’s weekly-to-biweekly cadence. When the strategy selection step queries the broker abstraction for option chains, it uses EXPIRATION_MAX together with EXPIRATION_MIN to filter contracts by DTE before evaluating deltas, yields, and liquidity. Anything with DTE above this cap is dropped from consideration immediately, so only expirations within [EXPIRATION_MIN, EXPIRATION_MAX] flow forward into delta targeting and sizing. Where MAX_RISK governs the total portfolio dollars at stake, EXPIRATION_MAX governs time exposure and assignment/roll frequency; and like YIELD_MAX, it functions as a hard screening threshold rather than a scoring input. Centralizing EXPIRATION_MAX here lets both the strategy selector and order execution apply the same expiration gate without touching credentials or duplicating logic, ensuring consistent behavior throughout options-wheel-main_cleaned.
# file path: config/params.py
OPEN_INTEREST_MIN = 100OPEN_INTEREST_MIN fixes the minimum contracts of open interest an option must have to be considered by the wheel, acting as a liquidity gate in the same family of tunable knobs as DELTA_MIN but focused on tradeability rather than moneyness. When the broker abstraction delivers an options chain and the contract model has normalized those contracts, the strategy selection stage reads OPEN_INTEREST_MIN and immediately filters out strikes with thinner markets, so only candidates with at least 100 contracts outstanding proceed to yield and delta evaluation, scoring, and ultimately to order creation through Alpaca. This parameter is imported wherever candidate screening happens so every component applies the same liquidity floor, as hinted by the import that pulls OPEN_INTEREST_MIN alongside other params. Compared to SCORE_MIN, which is a downstream quality threshold applied after computing a composite score, OPEN_INTEREST_MIN is a hard pre-filter to avoid wide spreads and poor fills; compared to EXPIRATION_MIN, which bounds the time window in days to expiration, OPEN_INTEREST_MIN bounds the market depth. Together with the previously covered DELTA_MIN, it ensures the system only sells puts or covered calls that are both in the desired risk band and liquid enough to enter and manage, reducing the chance that a candidate advances to execution but then cannot be filled efficiently.
# file path: config/params.py
SCORE_MIN = 0.05SCORE_MIN sets the floor for the composite quality score the strategy uses when ranking option candidates before any trade is considered. After market data comes in through the broker abstraction and contracts are normalized, the selection logic computes a single score that blends several signals—think delta alignment, yield, liquidity, and term fit—and then compares that value to SCORE_MIN to decide whether a candidate even enters the sizing and execution path. Where DELTA_MIN, which we already discussed, constrains a single dimension of moneyness, and YIELD_MIN enforces a per-contract income threshold, SCORE_MIN is a holistic gate that requires the overall signal package to clear a minimum bar. Modules that import SCORE_MIN from config.params alongside DELTA_MIN, YIELD_MIN, YIELD_MAX, and OPEN_INTEREST_MIN apply these gates consistently so the options-wheel-main_cleaned loop only advances candidates whose composite merit justifies risk, before handing them off to risk checks keyed by MAX_RISK and, if they pass, to order submission via the Alpaca integration.
# file path: pyproject.toml
[build-system]
requires = [”setuptools>=61.0”]
build-backend = “setuptools.build_meta”
[project]
name = “options-wheel”
version = “0.1.0”
description = “An automated options wheel trading strategy.”
authors = [
{ name = “Your Name”, email = “your.email@example.com” }
]
readme = “README.md”
requires-python = “>=3.8”
dependencies = [
“python-dotenv”,
“pandas>=1.5”,
“numpy>=1.23”,
“requests>=2.28”,
“alpaca-py”
]
[project.scripts]
run-strategy = “scripts.run_strategy:main”
[tool.setuptools.packages.find]
where = [”.”]
exclude = [”archive”]pyproject.toml is the packaging and execution contract for the options-wheel app: it tells build tools to use setuptools to assemble the package, declares the project’s name, version, and README, and pins the minimum Python version so the environment running the wheel is predictable. The dependencies list supplies the runtime libraries the architecture relies on: python-dotenv enables the earlier load_dotenv call so ALPACA_API_KEY, ALPACA_SECRET_KEY, and IS_PAPER are available from the environment; pandas and numpy back the data shaping and scoring the strategy uses when turning market data into candidates; requests provides generic HTTP when needed; and alpaca-py is the broker and market data backbone that the broker abstraction wraps. The console entry mapping exposes a run-strategy command that calls the main function in the scripts.run_strategy module; that is the operator’s door into the orchestrated loop where modules import MAX_RISK, DELTA_MIN, and other parameters and then coordinate BrokerClient, state management, and execution. Package discovery is configured to include the project’s Python packages from the repository root while skipping the archive directory, which ensures modules like config.credentials, config.params, and the core and logging packages referenced by the imports you saw are installed and importable when the command-line entry point starts. In short, while the import blocks elsewhere express what the runtime pulls in during execution, pyproject.toml is what guarantees those modules and third-party libraries are present and that the application can be launched consistently as a packaged tool to run the automated options wheel.
Download the source code using the button below:



