Corporate actions

Splits, dividends, mergers, spin-offs, ticker changes, return of capital. Per-lot basis survives issuer events.

A corporate action is an issuer event that changes position characteristics without a trade. A 2-for-1 split doubles shares and halves per-share basis. A spin-off creates a new position with basis allocated from the parent. A cash merger closes every lot at the announced cash price.

If the SDK ignores these events, per-lot basis drifts from reality. horizon.corp_actions adjusts the LotBook correctly so tax packets, statements, and wash-sale detection stay honest.

Additive. No hot-path change. All events run after the fact against the lot book.

Import

python
from horizon.corp_actions import (
    ActionKind,
    CorporateAction,
    CorporateActionEngine,
    ApplyResult,
    CashCredit,
    GainAccrual,
    MergerType,
)

Supported kinds

KindEffect on lotsCashGain
SplitMultiply qty, divide basis-per-share
StockDividendNew zero-basis lots
CashDividendNo lot changeyes
MergerCashClose all lots at cash priceyesyes
MergerStockRewrite lots to acquirer, preserve total basis
SpinOffSplit basis between parent and spinco
TickerChangeRewrite market_id on lots
ReturnOfCapitalReduce basis per share; excess is gainyessometimes

Quickstart

python
from datetime import datetime, timezone
from horizon.state import LotBook
from horizon.corp_actions import CorporateAction, CorporateActionEngine

book = LotBook()
book.open_lot("AAPL", "buy", 100, 180.0,
              acquired_at=datetime(2024, 1, 15, tzinfo=timezone.utc),
              account_id="acc_jane")

engine = CorporateActionEngine(audit_log=audit_log)

# 2-for-1 split on 2025-06-01
action = CorporateAction.split(
    market_id="AAPL",
    ex_date=datetime(2025, 6, 1, tzinfo=timezone.utc),
    numerator=2, denominator=1,
)
result = engine.apply(action, book)

lot = book.open_lots(market_id="AAPL")[0]
# qty 200 @ $90; total basis 18000 (preserved from 100 @ $180).

engine.apply(...) returns an ApplyResult with:

  • cash_credits: dividends, cash merger proceeds, ROC distributions.
  • gain_accruals: realized gains (cash mergers, ROC overdraw).
  • adjusted_lot_ids, new_lot_ids, closed_lot_ids: for the audit trail.

Post cash_credits to the cash side of your ledger; feed gain_accruals into the tax packet.

Invariants

The engine preserves these across every kind (pre-tax):

  • Conservation of basis for splits, stock mergers, spin-offs, and ticker changes.
  • Conservation of quantity for splits, ticker changes, and (with ratio = 1.0) stock mergers.
  • Holding-period preserved. TaxLot.acquired_at carries over, so the short-term / long-term classification is unaffected.
  • Wash-sale carryover preserved. On splits, disallowed_loss_carryover stays on the lot and folds into the new per-share basis via cost_basis_per_share. On stock mergers and spin-offs, the carryover is absorbed into the new acquired_price.

Each handler has tests that assert these invariants on specific scenarios. See tests/test_l2_corp_actions.py.

Worked examples

Forward split

python
# Pre:  100 shares @ $180 (basis $18,000)
action = CorporateAction.split(market_id="AAPL", ex_date=...,
                                numerator=2, denominator=1)
# Post: 200 shares @ $90  (basis $18,000)

Reverse splits use the reciprocal: numerator=1, denominator=10 for a 1-for-10 reverse.

Cash dividend

python
# Pre: 100 shares @ $180
action = CorporateAction.cash_dividend(
    market_id="AAPL", ex_date=...,
    cash_per_share=0.50,
)
# Lots unchanged. CashCredit for $50 per holding account.

Cash merger

python
# Pre: 100 shares @ $40 in TARG (basis $4,000)
action = CorporateAction.merger_cash(
    market_id="TARG", ex_date=...,
    cash_per_share=50.0,
)
# Post: lot closed. CashCredit $5,000. GainAccrual $1,000 realized.

Stock merger

python
# Pre: 100 shares @ $40 in TARG
action = CorporateAction.merger_stock(
    market_id="TARG", ex_date=...,
    acquirer_market_id="ACQR", exchange_ratio=0.5,
)
# Post: 50 shares @ $80 in ACQR (basis $4,000 preserved)

Spin-off

python
# Pre: 100 shares of PARENT @ $100 (basis $10,000)
action = CorporateAction.spinoff(
    market_id="PARENT", ex_date=...,
    spinoff_market_id="SPINCO",
    spinoff_ratio=0.5,            # 0.5 spinco share per parent share
    basis_allocation_pct=0.80,    # 80% stays with parent
)
# Post: 100 PARENT @ $80 (basis $8,000)
#        50 SPINCO @ $40 (basis $2,000)
# Total basis preserved: $10,000.

The basis_allocation_pct comes from the issuer’s Form 8937 (IRS-required disclosure of the basis allocation for the spin-off). Typically based on the ex-date FMV ratio.

Ticker change

python
# Facebook -> Meta, 2022 (example)
action = CorporateAction.ticker_change(
    market_id="FB", ex_date=...,
    new_market_id="META",
)
# Lots rewritten; quantity and basis unchanged.

Return of capital

python
# Pre: 100 shares @ $20 (basis $2,000)
action = CorporateAction.return_of_capital(
    market_id="ROCTRUST", ex_date=...,
    cash_per_share=1.0,
)
# Post: 100 shares @ $19 (basis $1,900). CashCredit $100.

When ROC exceeds basis, the excess is recognized as a gain:

python
# Pre: 100 shares @ $0.50 (basis $50)
action = CorporateAction.return_of_capital(
    market_id="ROCTRUST", ex_date=...,
    cash_per_share=1.0,
)
# Post: 100 shares @ $0 (basis exhausted).
#       CashCredit $100.
#       GainAccrual $50 realized.

Per-account targeting

python
engine.apply(action, book, account_id="acc_jane")

Only that account’s lots on action.market_id are affected. Omit account_id= to affect every account.

Replaying a batch

python
actions = load_from_provider(...)
result = engine.replay(actions, book)

Actions process in ex_date order. The merged ApplyResult carries cash credits, gain accruals, and lot-id lists across all events.

Audit trail

Every apply() emits an AuditCategory.CorporateAction event with the full action dict and the affected lot ids. Cash dividends additionally emit AuditCategory.DividendAccrued per credited account for filtered downstream consumers (alerters, reporting).

Examiners can reconstruct per-lot basis at any point in history by replaying the log.

Out of scope (for now)

  • Mixed cash + stock mergers. MergerType.Mixed is in the enum for future use; v1 handles Cash and Stock only.
  • Rights issues. The price of a right and whether it is exercised are per-firm decisions. Model as two explicit events: a cash dividend (for any sold rights) plus an open-lot at the exercise price.
  • Reorganizations with cash-in-lieu of fractional shares. Round in the split handler, then emit a separate cash_dividend for the fractional proceeds. The data source must provide both.
  • Tax-character shifts on special dividends. Some corp actions (e.g. qualified vs. ordinary, Sec 301 distributions) have subtle tax-character differences not modeled here. The generated CashCredit.kind is a signal; the 1099-DIV preparation is downstream.
  • Data source integration. This module accepts CorporateAction objects you construct. Polygon, IBKR, Refinitiv, and many other providers publish corp-action feeds; wiring one is a per-deployment task.

Related