Build a complete single-leg options trading workflow with Python, from discovering contracts and analyzing Greeks to validating and placing your first order.
Options trading requires more than a way to submit orders. Before executing a trade, you must identify available contracts, retrieve market prices, evaluate risk, calculate estimated costs, and monitor execution.
The Public API brings these capabilities together through a single programmatic interface. With the official Python SDK, publicdotcom-py, traders and developers can easily integrate options market data and trading functionality into their existing programmatic trading workflows, or build a new one.
In this guide, we'll build a Python application that:
All code can be found on GitHub.
We'll use AAPL call options throughout the tutorial. By the end, you'll have a reusable foundation for more advanced applications, including options scanners, automated execution systems, and risk-management tools.
⚠️ Important
This tutorial uses a live brokerage API. Order submission is disabled by default. Enabling it can submit real orders involving real capital. Options involve significant risk, including the potential loss of the entire premium paid.
Before writing any code, you'll need a Public brokerage account with API access and the appropriate options trading permissions.
Prerequisites:
You can find additional details about authentication and available endpoints in the Quickstart.
Public maintains an official Python SDK that provides typed models and methods for interacting with the API.
Create a project directory and a virtual environment:
mkdir public-options-tutorial
cd public-options-tutorial
python3 -m venv .venv
source .venv/bin/activateThen, inside your activated environment, install the SDK and python-dotenv:
pip install publicdotcom-py==0.1.28 python-dotenvThe SDK package is distributed through PyPI and is also available on GitHub.
Create a .env file in your project directory:
API_SECRET_KEY=your_public_api_secret_key
DEFAULT_ACCOUNT_NUMBER=your_account_number
DRY_RUN=trueReplace the placeholder values with your Public API credentials and brokerage account number.
The DRY_RUNenvironment variable controls whether our application is allowed to submit orders. We'll leave it enabled throughout the initial development process.
🔒 Important
Treat your API key like you would a password. Do not commit it or share it.
Create a Python file named options_trading.py.
We'll start by importing the SDK and configuring our authenticated client:
import os
import uuid
from decimal import Decimal
from dotenv import load_dotenv
from public_api_sdk import (
ApiKeyAuthConfig,
PublicApiClient,
PublicApiClientConfiguration,
)
load_dotenv()
api_secret_key = os.environ["API_SECRET_KEY"]
account_number = os.environ["DEFAULT_ACCOUNT_NUMBER"]
client = PublicApiClient(
ApiKeyAuthConfig(api_secret_key=api_secret_key),
config=PublicApiClientConfiguration(
default_account_number=account_number
),
)The PublicApiClient handles API authentication and access-token refresh automatically.
By specifying a default account number, we also avoid having to provide an account identifier with every request.
To confirm that authentication is working, retrieve the accounts associated with your API credentials:
accounts = client.get_accounts()
for account in accounts.accounts:
print(
f"Account: {account.account_id} | "
f"Type: {account.account_type}"
)If authentication is successful, the response will contain your accessible brokerage accounts.
You now have an authenticated client ready to retrieve market data and interact with trading endpoints.
Before retrieving an options chain, it's helpful to understand how individual options contracts are represented.
An option is a derivative contract whose value is related to an underlying asset, such as a stock or exchange-traded fund.
There are two basic types:
A single-leg options order involves buying or selling one options contract rather than combining multiple contracts into a more complex strategy such as a spread.
U.S. listed options commonly use the Options Symbology Initiative (OSI) format. This is how Public identifies an options symbol as well.
For example, consider this hypothetical contract:
AAPL270115C00200000It represents:
AAPL: The underlying symbol.270115: January 15, 2027.C: Call option.00200000: A $200 strike price.The symbol provides a unique way to identify the option contract when requesting market data or submitting orders.
In this tutorial, we won't construct symbols manually. Instead, we'll retrieve valid symbols directly from Public's option-chain endpoint. That avoids hardcoding expired or unavailable contracts.
Options trading starts with contract discovery.
For a stock such as AAPL, there can be hundreds of available contracts across different expirations, strikes, and option types.
The Public API provides two endpoints that simplify this process:
First, create an instrument representing AAPL:
from public_api_sdk import (
InstrumentType,
OptionChainRequest,
OptionExpirationsRequest,
OrderInstrument,
)
underlying = OrderInstrument(
symbol="AAPL",
type=InstrumentType.EQUITY,
)Next, request its available expiration dates:
expirations = client.get_option_expirations(
OptionExpirationsRequest(
instrument=underlying
)
)
for expiration in expirations.expirations[:10]:
print(expiration)The API returns a list of currently available expiration dates.
For example, a hypothetical response might look like:
2026-11-06
2026-11-13
2026-11-20
2026-11-27
2026-12-04
2026-12-18
2027-01-15For our example, we'll select the nearest available expiration:
if not expirations.expirations:
raise RuntimeError("No option expirations available")
selected_expiration = sorted(expirations.expirations)[0]
print(f"Selected expiration: {selected_expiration}")In a production application, you might use additional criteria, such as targeting contracts with 30–45 days remaining until expiration.
For now, selecting the nearest expiration keeps the example straightforward. It is a demonstration choice, not a recommendation to trade short-dated options.
With an expiration selected, request the corresponding options chain:
chain = client.get_option_chain(
OptionChainRequest(
instrument=underlying,
expiration_date=selected_expiration,
)
)The response contains available call and put contracts, including their symbols and market-data fields.
Let's inspect the first five calls:
for call in chain.calls[:5]:
print(
f"Contract: {call.instrument.symbol} | "
f"Bid: {call.bid} | "
f"Ask: {call.ask}"
)This should output something like:
Contract: AAPL261106C00110000 | Bid: 234.50 | Ask: 236.20
Contract: AAPL261106C00120000 | Bid: 224.50 | Ask: 226.20
Contract: AAPL261106C00125000 | Bid: 219.50 | Ask: 221.20
Contract: AAPL261106C00130000 | Bid: 214.50 | Ask: 216.20
Contract: AAPL261106C00135000 | Bid: 209.50 | Ask: 211.20Each contract is identified by an OSI symbol that can be reused in subsequent market-data and order requests.
For this demonstration, we'll select the first call with a valid, positive bid and ask.
eligible_calls = [
call
for call in chain.calls
if call.bid is not None
and call.ask is not None
and Decimal(str(call.bid)) > 0
and Decimal(str(call.ask)) > 0
and Decimal(str(call.ask)) >= Decimal(str(call.bid))
]
if not eligible_calls:
raise RuntimeError(
"No call options with valid bid/ask prices"
)
selected_call = eligible_calls[0]
option_symbol = selected_call.instrument.symbol
print(f"Selected contract: {option_symbol}")This selection rule is deliberately simple. It demonstrates how to work with the returned contracts without introducing a trading strategy.
A production options scanner should use more sophisticated criteria, including expiration, strike, liquidity, implied volatility, and Greeks.
For this tutorial, our goal is to identify a valid contract that can be used throughout the remaining workflow.
Now that we've selected an options contract, let's retrieve its market data.
Unlike stocks, options have additional characteristics that influence their prices, including expiration, implied volatility, and sensitivity to movements in the underlying.
The Public API provides access to option quotes and Greeks, allowing developers to incorporate these characteristics into their applications.
Use get_quotes() with the selected OSI symbol:
option_instrument = OrderInstrument(
symbol=option_symbol,
type=InstrumentType.OPTION,
)
quotes = client.get_quotes([option_instrument])
if not quotes:
raise RuntimeError("No quote returned")
quote = quotes[0]
print(f"Contract: {quote.instrument.symbol}")
print(f"Last: {quote.last}")
print(f"Bid: {quote.bid}")
print(f"Ask: {quote.ask}")The bid and ask represent the prices at which market participants are currently willing to buy and sell the option, subject to the displayed market size and current conditions.
The difference between these prices is the bid/ask spread.
A smaller spread generally indicates lower immediate trading friction, although available size and market conditions also matter.
We can calculate the midpoint programmatically:
bid = Decimal(str(quote.bid))
ask = Decimal(str(quote.ask))
if bid <= 0 or ask <= 0 or ask < bid:
raise RuntimeError("Invalid option quote")
midpoint = (bid + ask) / Decimal("2")
print(f"Midpoint: ${midpoint:.2f}")For a standard equity option with a 100-share multiplier, a $2.45 premium corresponds to a quoted contract value of approximately $245 before fees.
Actual execution may occur at a different price. The midpoint is not a guaranteed fill price.
The Greeks describe how an option's theoretical value responds to changes in market conditions.
The Public API provides a dedicated endpoint for retrieving Greeks for individual option contracts.
greeks_response = client.get_option_greek(
osi_symbol=option_symbol
)
greeks = greeks_response.greeks
if greeks:
print(f"Delta: {greeks.delta}")
print(f"Gamma: {greeks.gamma}")
print(f"Theta: {greeks.theta}")
print(f"Vega: {greeks.vega}")
print(
f"Implied volatility: "
f"{greeks.implied_volatility}"
)
else:
print("Greeks unavailable for this contract")Here is what each metric tells us:
A delta of 0.45 means the option's theoretical value would initially change by approximately $0.45 for a $1 movement in AAPL, assuming other variables remain constant.
These are model-based sensitivities, not price guarantees. Values can change rapidly, particularly near expiration. See Greeks for more detail.
Combining option-chain discovery with quotes and Greeks opens the door to more sophisticated applications.
For example, developers can build scanners that identify contracts based on:
In future tutorials, we'll use these capabilities to construct a configurable options scanner.
For now, let's use our selected contract to prepare an order.
The Public API supports single-leg options order submission through its order-placement endpoints.
For our first example, we'll construct a buy-to-open limit order for one AAPL call contract.
Buy to open means we're purchasing an options contract to establish a new long position.
A limit order lets us specify the maximum premium we're willing to pay for that contract.
| Parameter | Value | Purpose |
|---|---|---|
| Instrument | Selected AAPL call | Identifies the option |
| Order side | BUY | Purchase the contract |
| Open/close indicator | OPEN | Open a new position |
| Order type | LIMIT | Control the maximum premium |
| Quantity | 1 | Trade one contract |
| Time in force | DAY | Order remains eligible for the trading day |
The open/close indicator is particularly important for options trading.
A buy order can either open a new long position or close an existing short position. The indicator communicates which transaction the developer intends to perform.
We'll use an illustrative limit price of $2.45:
from public_api_sdk import (
OpenCloseIndicator,
OrderExpirationRequest,
OrderSide,
OrderType,
PreflightRequest,
TimeInForce,
)
limit_price = Decimal("2.45")
preflight_request = PreflightRequest(
instrument=option_instrument,
order_side=OrderSide.BUY,
open_close_indicator=OpenCloseIndicator.OPEN,
order_type=OrderType.LIMIT,
expiration=OrderExpirationRequest(
time_in_force=TimeInForce.DAY
),
quantity=1,
limit_price=limit_price,
)The example premium is not a live quote or trading recommendation.
In an actual application, the selected limit price should be based on current market data, the contract's permitted price increments, and the application's trading rules.
Before submitting this request, we can use the API to estimate its impact.
One useful feature of the Public API is the ability to perform a preflight calculation before placing a trade.
Preflight helps developers evaluate an order's estimated cost and whether it satisfies applicable trading requirements.
Rather than immediately sending an order to the market, our application can first request an estimate.
Using our existing request:
preflight = client.perform_preflight_calculation(
preflight_request
)The response includes order-related estimates such as order value and applicable fees.
print(f"Order value: {preflight.order_value}")
print(
"Estimated commission:",
preflight.estimated_commission
)
print(
"Estimated execution fee:",
preflight.estimated_execution_fee
)For example, if the premium is $2.45 and the contract multiplier is 100, the order's gross premium would be approximately:
$2.45 × 100 × 1 contract = $245.00Fees, rebates, buying-power requirements, and any additional charges should be evaluated using the actual preflight response and applicable account rules.
This is especially valuable for automated systems. Before an application submits an order, it can incorporate preflight estimates into its own risk controls.
For example, a trading bot might require that:
Preflight is not an execution guarantee. Prices can change between validation and submission, and orders may still be rejected or remain unfilled.
Developers should also plan for unsuccessful preflight requests.
Common causes include insufficient buying power, missing trading permissions, invalid order values, and unsupported transaction parameters.
For production applications, these errors should be logged with enough detail to explain why an order was not submitted.
Preflight therefore acts as an important checkpoint between identifying a trading opportunity and placing an order.
Once an order passes preflight and any application-level risk checks, it can be submitted through the Public API.
We'll use the SDK's place_order() method.
Order submission requires a unique order identifier, which we can generate using Python's uuid library.
from public_api_sdk import OrderRequest
order_request = OrderRequest(
order_id=str(uuid.uuid4()),
instrument=option_instrument,
order_side=OrderSide.BUY,
open_close_indicator=OpenCloseIndicator.OPEN,
order_type=OrderType.LIMIT,
expiration=OrderExpirationRequest(
time_in_force=TimeInForce.DAY
),
quantity=1,
limit_price=limit_price,
)Using a unique identifier helps the application track a specific order request.
For systems that retry requests after network failures, order identifiers should be persisted and reused according to the API's idempotency behavior rather than generated anew on every retry.
Before submitting, check our DRY_RUN configuration:
dry_run = (
os.getenv("DRY_RUN", "true").lower() != "false"
)
new_order = None
if dry_run:
print("DRY_RUN enabled.")
print("Order placement skipped.")
else:
new_order = client.place_order(order_request)
print(f"Order ID: {new_order.order_id}")With DRY_RUN=true, the application retrieves market data and runs preflight without placing an order.
To enable live submission, a developer must explicitly change the configuration to:
DRY_RUN=falseThis should only be done after reviewing the selected contract, quantity, limit price, and account.
When place_order() returns successfully, the API has returned an order handle.
That does not necessarily mean the trade has been executed.
An order can be accepted but remain open while waiting for a matching price. It may also be partially filled, canceled, expired, or rejected.
Applications should therefore separate two concepts:
The next step is to monitor the order until its status changes.
After submitting an order, most trading applications need a reliable way to track its status.
The Public Python SDK provides order-management methods that simplify this workflow.
When an order is submitted, place_order() returns a NewOrder object that exposes methods for retrieving status and waiting for updates. The examples in this section assume an order was actually submitted (DRY_RUN=false), so new_order is not None.
For a newly submitted order:
order = new_order.get_details()
print(f"Status: {order.status}")
print(
f"Filled quantity: "
f"{order.filled_quantity}"
)The response can tell us whether the order is still open, has partially filled, or has reached a terminal state. If you only need the status itself, new_order.get_status() returns it directly.
If an order fills, you can also inspect its execution price.
💡 Streaming order updates
Instead of polling, you can subscribe to the Order Updates WebSocket to receive order status changes as they happen, and to the Trades WebSocket to receive each fill.
The SDK provides a convenience method for waiting for a fill.
from public_api_sdk import WaitTimeoutError
try:
filled_order = new_order.wait_for_fill(
timeout=60
)
print("Order filled.")
print(
f"Average price: "
f"{filled_order.average_price}"
)
except WaitTimeoutError:
print(
"Order did not fill within 60 seconds."
)This demonstrates an important principle of programmatic trading: placing an order and receiving an execution are separate events.
A timeout only means the expected status was not observed within the requested period. The order may still be open or partially filled, so a production application must reconcile its status before taking further action.
If an order is no longer needed, the API provides cancellation functionality.
client.cancel_order(
order_id=new_order.order_id
)Cancellation is asynchronous.
After requesting cancellation, retrieve the order status to confirm its final state. An order could still execute while the cancellation request is being processed.
This is particularly important for trading bots, where a cancellation request should never be treated as proof that no position was opened. See Order lifecycle for the full set of order states.
Once an options order fills, the position should appear in the brokerage account's portfolio, subject to processing and data-update timing.
The Public API provides portfolio endpoints that let developers retrieve positions, account balances, and related information.
With the Python SDK:
portfolio = client.get_portfolio()
for position in portfolio.positions:
print(
f"Symbol: {position.instrument.symbol}"
)
print(f"Position: {position}")In a complete application, you'd identify the position matching the OSI contract symbol submitted earlier.
From there, you could build functionality to monitor the position's current valuation and related market data.
For example, an options dashboard could periodically retrieve account positions, current options quotes, and Greeks to show how portfolio exposure changes over time.
It's important to distinguish existing positions from pending orders. An unfilled buy order doesn't establish a new long options position.
Similarly, partial fills and delayed portfolio updates should be handled explicitly.
We've now walked through the major components of a single-leg options workflow using the Public API:
These steps can be combined into a reusable Python application that takes an underlying symbol, option type, expiration criteria, contract quantity, and limit price as inputs.
The application can then follow a consistent process, reducing the amount of integration code needed for more complex projects.
The full script on GitHub can be extended with configurable contract selection, structured logging, error handling, and additional order-management functionality.
The same workflow can serve as the starting point for:
The key advantage is that each application can reuse the same underlying market-data and order-execution interfaces.