Skip to content

Latest commit

 

History

History
225 lines (187 loc) · 11.7 KB

README.md

File metadata and controls

225 lines (187 loc) · 11.7 KB

optionstruct

Pricing units

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 + Decimal only. 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 UNBOUNDED rather 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

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 strikes

A 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

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.

Held legs

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 ContractFacts for 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 reported GROUP, 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 are UNBOUNDED. 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_fee per 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.

Model

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.

Greeks

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.

Order-limit pricing

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.

Scope

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.

License

MIT.