Option exercise and assignment
Tax-lot mutations at option expiry. Long / short calls and puts; ITM exercise, ITM assignment, OTM worthless.
At expiry, one of three things happens to every option contract:
- Long option is ITM: the holder exercises.
- Short option is ITM: the writer is assigned.
- Option is OTM: it expires worthless.
horizon.options.exercise.OptionExerciseEngine applies the correct IRS-aligned basis adjustment to the LotBook in each case. Without this, per-lot basis on the underlying drifts from reality whenever options finish ITM or OTM.
Additive. Runs after the fact against the lot book. Not on the hot path.
Import
from horizon.options import (
OptionAction,
OptionEvent,
OptionExerciseEngine,
OptionExerciseResult,
parse_option_market_id,
)
Symbology
Market ids follow the OCC-style convention shared with the IBKR adapter:
OPT:<SYMBOL>:<YYYYMMDD>:<STRIKE>:<C|P>
Example: OPT:AAPL:20260619:180:C is the AAPL 180 call expiring 2026-06-19.
parse_option_market_id(id) returns (underlying, strike, right) or None. For venues with different symbology, pass underlying_market_id=, strike=, and right= explicitly to OptionEvent.
The contract multiplier defaults to 100 (standard equity and ETF options, SPX). Mini options use 10. Override via multiplier= on OptionEvent.
Quickstart
from datetime import datetime, timezone
from horizon.state import LotBook
from horizon.options import OptionExerciseEngine, OptionEvent
book = LotBook()
# Bought 1 AAPL 180C at $5.00 premium
book.open_lot("OPT:AAPL:20260619:180:C", "buy", 1, 5.00,
acquired_at=datetime(2026, 4, 1, tzinfo=timezone.utc),
account_id="acc_jane", multiplier=100)
engine = OptionExerciseEngine(audit_log=audit_log)
# ITM at expiry; holder exercises
event = OptionEvent.exercise(
market_id="OPT:AAPL:20260619:180:C",
qty_contracts=1,
effective_at=datetime(2026, 6, 19, 20, 0, tzinfo=timezone.utc),
)
result = engine.apply(event, book, account_id="acc_jane")
# Option lot closed; new AAPL lot opened with basis = 180*100 + 500 = 18,500
# (i.e. $185 per share; the premium rolled into basis)
The six cases
Long call, exercised (ITM)
The call premium absorbs into the new stock’s basis.
Pre: 1 contract of AAPL 180C at $5.00 ($500 premium)
Post: 100 shares of AAPL at $185.00 basis per share (total basis $18,500)
Option lot closed at $0; no option-level P&L.
Holding period for the underlying starts the day after exercise (IRS).
Long put, exercised (ITM)
The put premium reduces the sale proceeds from the underlying.
Pre: 100 shares of AAPL at $150 basis
+ 1 contract of AAPL 140P at $3.00 ($300 premium)
Post: Underlying closed at effective proceeds $137/share
Realized: (137 - 150) * 100 = -$1,300
Option lot closed at $0.
Long option, OTM at expiry
Full premium is the realized loss.
Pre: 1 contract of AAPL 200C at $2.00 ($200 premium)
Post: Option lot closed; realized_pnl = -$200.
Short call, assigned (ITM)
The premium received adds to the sale proceeds of the underlying.
Pre: 100 shares of AAPL at $170 basis (covered)
+ short 1 AAPL 180C at $2.50 (received $250)
Post: Underlying closed at effective proceeds $182.50/share
Realized: (182.50 - 170) * 100 = +$1,250
Option lot closed at $0.
Short put, assigned (ITM)
The premium received reduces the basis of the newly-acquired stock.
Pre: Short 1 AAPL 180P at $3.00 (received $300)
Post: 100 shares of AAPL at $177 basis per share (total basis $17,700)
Option lot closed at $0.
Short option, OTM at expiry
Full premium is the realized gain.
Pre: Short 1 AAPL 200C at $2.00 (received $200)
Post: Option lot closed; realized_pnl = +$200.
Partial exercise / assignment
Full expiry is all-or-nothing per contract in practice, but the engine supports partials for manual overrides. If qty_contracts is less than the open contract count, only the specified portion closes; the remainder stays open.
# 10 contracts open; operator exercises 3
engine.apply(OptionEvent.exercise(
market_id=opt_id, qty_contracts=3, effective_at=t,
), book, account_id="acc_1")
Per-account targeting
Pass account_id= to only touch one account’s option lots. Omit to apply across every account that holds that contract.
Result
OptionExerciseResult carries:
option_realized_pnl_usd: gain / loss on the option side (non-zero only for worthless expiry).underlying_realized_pnl_usd: gain / loss on the underlying side (long put exercise, short call assignment).new_underlying_lot_id: id of the new stock lot (long call exercise, short put assignment).closed_option_lot_ids,closed_underlying_lot_ids: for audit.
Audit events
| Action | Category |
|---|---|
| Exercise | AuditCategory.OptionExpiry |
| Assignment | AuditCategory.OptionAssigned |
| ExpireWorthless | AuditCategory.OptionExpiry |
Each event payload carries the full event description (underlying, strike, right, qty, multiplier, effective_at) plus affected lot ids. Examiners can reconstruct per-lot basis post-expiry from the log alone.
Wiring into a run loop
from horizon.options import OptionExerciseEngine, OptionEvent
engine = OptionExerciseEngine(audit_log=audit_log)
# At session close or end-of-day, process any expirations:
for event in expiry_feed.pending_expirations(today):
engine.apply(event, lot_book, account_id=event.account_id)
expiry_feed is whatever source you use to learn which contracts finished ITM and were exercised vs. assigned. Broker statements, DTCC, or the broker’s API all publish this; the adapter layer translates into OptionEvent objects.
Out of scope
- American-style mid-life exercise. The engine treats exercise as an instantaneous event; intraday pin risk and assignment randomness are out of scope.
- Dividend-driven early exercise. Some holders exercise calls the day before ex-dividend to capture the dividend. Model as a manual
exerciseevent dated before the ex-date. - Cash-settled index options. SPX and RUT settle to cash, not stock. The engine assumes physical settlement. For cash settlement, model the expiry as a
merger_cash-style event or use a custom handler. - Wash-sale treatment on OTM expiry. A loss on a worthless option can still trigger a wash sale if replaced within 30 days. The engine closes the lot;
WashSaleDetectordoes the rest against the lot history. - Holding-period carryover on long call exercise. Per IRS the underlying’s holding period starts the day after exercise; the engine sets
acquired_attoeffective_at. That is slightly aggressive (shortens holding period by a day). Fine-grain holders can adjust manually.
Related
- Tax lots for lot-level mechanics and wash-sale detection.
- Corporate actions for issuer events that similarly mutate lots.
- Tax packets consumes realized P&L for 1099-B.
- IBKR adapter for the OCC-symbol convention.