PricingInputs.volatility is always an annual fraction: Decimal('1.20')
means 120%. Convert a provider's explicitly percentage-valued field at its
boundary (Decimal('20') / Decimal('100')); the library never guesses from
magnitude. All pricing inputs and analytical outputs must be finite. Greeks
use delta/gamma per $1 underlying, theta per calendar day, and vega per one
percentage point of volatility. Positive fractional time remains valid on
expiration day; zero/past expiration uses exact intrinsic/payoff math instead.
Dependency-free, broker-agnostic modeling of multi-leg options structures in pure Python. Build a structure from legs, then ask it for exact expiration-payoff risk figures in real dollars: max loss, max profit, breakevens, defined-risk margin.
- No dependencies. Stdlib +
Decimalonly. No pydantic, no Django, no broker SDK, no network or disk I/O. Adapters that talk to a broker live in optional extras and are never imported by the core. - Exact, not sampled. Expiration P&L is piecewise-linear with kinks only at
strikes, so max/min and breakevens are computed at the vertices, no grids,
no approximation. Unbounded-risk structures (naked/ratio legs) report
UNBOUNDEDrather than a fake large number. - Construct, then validate. The core builds any structure without judgment (verticals, condors, ratios, debit or credit). Strategy checks such as "is this a short-put credit spread I'd actually trade?" are opt-in validators you call when you want them.
from datetime import date
from decimal import Decimal
from optionstruct import OptionContract, Leg, Structure, OptionType
exp = date(2026, 8, 21)
short = Leg.short(OptionContract("XSP", OptionType.PUT, "740", exp))
long_ = Leg.long(OptionContract("XSP", OptionType.PUT, "735", exp))
spread = Structure.vertical(short, long_)
premiums = {
short.contract.symbol: Decimal("3.00"),
long_.contract.symbol: Decimal("1.50"),
}
r = spread.risk(premiums)
r.net_premium # Decimal('150.00'), the credit collected, in dollars
r.max_loss # Decimal('350.00')
r.max_profit # Decimal('150.00')
r.breakevens # (Decimal('738.5'),)expiration_stress(structures, spot, move, multiples=(1, 2)) is the exact payoff
change of a whole book at expiration if the underlying settles multiple × move
below or above spot (a natural move is the average true range). Premiums cancel in
the difference, so none are needed. Each Stress carries both signed changes and the
worse one as a positive loss:
from optionstruct import expiration_stress
one_atr, two_atr = expiration_stress([spread], spot="750", move="8")
one_atr.down_change # Decimal('0'): 742 is still above the 740 short put
two_atr.down_change # Decimal('-500'): 734 is through both strikesA missing, zero or non-finite input raises StressInputError rather than reporting
a book with no risk. It is a conditional shock at expiration, not a mark-to-market
forecast or a probability.
chain_yield(quotes, spot=, width=, band_fraction=) prices every credit vertical
width wide in one expiration and option type at the mids, and ranks them by theta
per dollar at risk: what a book scored on decay per dollar of capital actually wants,
computed directly instead of hoping a target delta lands on it. Quotes are
OptionQuote(contract, bid, ask, greeks) per share, as a feed reports them; a quote
missing a side, crossed, or missing a greek is skipped, never defaulted.
from datetime import date
from decimal import Decimal as D
from optionstruct import (OptionContract, OptionQuote, OptionType, PricingInputs, bs_price,
chain_yield, greeks, row_at_delta)
spot, expiry, as_of = D('500'), date(2026, 11, 20), date(2026, 10, 21)
quotes = []
for strike in range(440, 511): # a synthetic chain with a put skew
contract = OptionContract('ABC', OptionType.PUT, strike, expiry)
vol = D('0.16') + D('0.9') * (spot - strike) / spot
inputs = PricingInputs.from_contract(contract, spot=spot, volatility=vol, as_of=as_of)
mid = bs_price(inputs).quantize(D('0.01'))
quotes.append(OptionQuote(contract, mid - D('0.05'), mid + D('0.05'), greeks(inputs)))
table = chain_yield(quotes, spot=spot, width='5', band_fraction='0.05')
table.best.short_strike, f'{table.best.theta_yield:.4%}' # (Decimal('476'), '0.2538%')
len(table.band) # 15 verticals within 5% of it
table.theta_peak_strike # Decimal('494')
by_delta = row_at_delta(table.rows, D('0.40'))
by_delta.short_strike, table.rows.index(by_delta) + 1 # (Decimal('494'), 50)Two things this shows. A vertical's net theta is the change in |theta| across its width, so the 0.40-delta short, which sits on the |theta| peak, collects what a flat curve pays: it ranks 50th of 66 at under a third of the best yield. And the best is not one strike but a band: fifteen verticals from 467 to 481 lie within 5% of it, while their dollar delta runs from $1,225 to $2,172. Among candidates the quotes cannot separate on yield, choose on something they can: what each one does to the book.
Take the band, not the argmax. On a live chain the top candidates routinely sit
closer together in yield than the same candidates move between two snapshots minutes
apart: the set holds, the leader does not, and the argmax picks whichever strike had
the luckiest quote. Set band_fraction from that noise rather than taste:
yield_drift(a, b) is the largest change in any shared vertical's yield between two
snapshots, as a fraction of the best, and rank_overlap(a, b, n) says how many of the
top n survived and whether the leader did. Every row carries its Structure, credit,
max_loss, net_theta ($/day), theta_yield (a fraction per day), short and net
delta, dollar delta, net gamma, and gamma per dollar of theta (what the yield was bought
with). strike_parity=True keeps shorts on even strikes and longs on odd ones.
group_held_legs(legs, facts, marks, today=, close_fee=) turns a broker's flat list of
position rows into verified structures, each with an upper bound on what it can lose
from its current value, and names every leg it cannot bound:
from datetime import date
from decimal import Decimal as D
from optionstruct import ContractFacts, ExerciseStyle, HeldLeg, OptionContract, group_held_legs
nov, dec = date(2026, 11, 20), date(2026, 12, 18)
p570, p565 = OptionContract('XSP', 'put', 570, nov), OptionContract('XSP', 'put', 565, nov)
c600 = OptionContract('XSP', 'call', 600, dec)
facts = [ContractFacts(c, root='XSP', exercise=ExerciseStyle.EUROPEAN, settlement='PM',
cash_settled=True) for c in (p570, p565)] # none for the call
rows = [HeldLeg(p570.symbol, -1, 100), HeldLeg(p565.symbol, 1, 100), # the broker's rows,
HeldLeg(p570.symbol, -1, 100), HeldLeg(p565.symbol, 1, 100), # one per lot
HeldLeg(c600.symbol, -1, 100)]
found = group_held_legs(rows, facts, today=date(2026, 10, 19), close_fee='1.00')
[(v.label, v.stress_loss, v.bound) for v in found.verified]
# [('XSP 2026-11-20 PM', Decimal('1004.00'), <StressBound.RANGE: 'range'>)] 2 x 500 + 4 x 1.00
[(u.symbol, u.reason) for u in found.unverified]
# [('XSP 261218C00600000', <UnverifiedReason.FACTS: 'facts'>)]- Net, then check. Rows for one symbol are summed. A leg needs
ContractFactsfor its contract, an agreeing underlying and multiplier, whole contracts, and an expiration not yet past. - A failure spoils its series. A leg that fails, or a row the caller could not read
(
unreadable=), makes every leg of its OCC root and expiration unverified. Had the 570 put been the leg without facts, the 565 would be reportedGROUP, not left behind as a cheap long that looks like the whole position. - Group by underlying, root, expiration and settlement time: AM and PM contracts, or SPX and SPXW, never net.
- Bound from the exact expiration payoff when every contract is European,
cash-settled and standard:
max(high, 0) - min(low, 0)over all settlement prices (RANGE), or the marked value less the payoff's minimum when every leg is marked consistently with that range (MARK), whichever is smaller. Net short calls areUNBOUNDED. A long-only group with an American, physically settled or adjusted contract can lose its marked value (VALUE), except on expiration day; with short legs it cannot be bounded this way at all. - Fees.
close_feeper contract is added, so the bound covers getting out.
Marks are optional per-share prices: they make a bound possible (long calls have no range bound) or tighter, never looser, and a mark outside the payoff's range is ignored as stale.
| Piece | What it is |
|---|---|
OptionContract |
underlying, type, strike, expiration, multiplier (default 100), OCC symbol |
Leg |
a contract + side (long/short) + quantity |
Structure |
N legs on one underlying + an inferred/declared StructureKind |
RiskProfile |
net_premium, max_profit, max_loss, breakevens (from structure.risk(premiums)) |
validate_* |
opt-in strategy validators, raise ValidationError |
is_even_strike / is_odd_strike |
strike-parity predicates for leg-netting-safe strike selection |
combined_margin |
netted margin across structures on one underlying+expiration |
PricingInputs / greeks |
pure Black-Scholes delta/gamma/theta/vega for one option |
net_greeks |
net greeks across legs (greek × multiplier × signed quantity) |
round_to_tick / opening_limit_credit |
limit-price math on an arbitrary tick grid |
chain_yield / YieldTable |
every vertical at one width ranked by theta per dollar at risk, and the tied band |
group_held_legs / VerifiedStructure |
broker position rows netted into structures with a stress-loss bound |
Premiums are supplied per-share, keyed by contract symbol; the package never fetches prices. Dollar figures apply each contract's multiplier and quantity.
optionstruct.pricing provides pure Black-Scholes greeks (bs_delta,
bs_gamma, bs_theta, bs_vega, or greeks() for all four) from a
PricingInputs. Units: delta/gamma per $1 underlying, vega per 1 percentage
point of vol, theta per calendar day. Rates/yield default to 0. This is for
offline and fallback use; live systems typically read greeks from a data feed.
net_greeks aggregates a book: it takes (Leg, Greeks) pairs and sums each
greek as greek × multiplier × signed_quantity, so shorts subtract and the
totals are share-equivalent. It does not care whether the greeks were streamed
from a feed or computed here, so a live system can use it with its own values.
round_to_tick(value, tick, rounding) rounds to any price grid (penny, nickel,
…). opening_limit_credit(mid, natural, entry_offset=...) prices a credit
structure's opening order: it shaves the demanded credit from the mid toward the
natural (immediate-fill) credit by an offset, floors that offset at natural so it
never gives up more than crossing the spread would, and rounds down. A fill-now
sell must never round to demand more than intended. The offset value itself is
policy and belongs to the caller.
Same-expiration structures are fully supported. Calendars and diagonals (legs at
different expirations) can be built, but the risk math raises
CalendarNotSupportedError: valuing the still-open leg at the near leg's
expiration needs a pricing model, which lands in a later release.
The package is intentionally broker-free and contains no order adapter.
examples/broker_mapping.py shows the pure Structure→order-legs mapping a
broker adapter performs, without importing any SDK.
MIT.