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:

  1. Long option is ITM: the holder exercises.
  2. Short option is ITM: the writer is assigned.
  3. 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

python
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

python
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.

python
# 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

ActionCategory
ExerciseAuditCategory.OptionExpiry
AssignmentAuditCategory.OptionAssigned
ExpireWorthlessAuditCategory.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

python
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 exercise event 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; WashSaleDetector does 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_at to effective_at. That is slightly aggressive (shortens holding period by a day). Fine-grain holders can adjust manually.

Related