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
from horizon.corp_actions import (
ActionKind,
CorporateAction,
CorporateActionEngine,
ApplyResult,
CashCredit,
GainAccrual,
MergerType,
)
Supported kinds
| Kind | Effect on lots | Cash | Gain |
|---|---|---|---|
Split | Multiply qty, divide basis-per-share | ||
StockDividend | New zero-basis lots | ||
CashDividend | No lot change | yes | |
MergerCash | Close all lots at cash price | yes | yes |
MergerStock | Rewrite lots to acquirer, preserve total basis | ||
SpinOff | Split basis between parent and spinco | ||
TickerChange | Rewrite market_id on lots | ||
ReturnOfCapital | Reduce basis per share; excess is gain | yes | sometimes |
Quickstart
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_atcarries over, so the short-term / long-term classification is unaffected. - Wash-sale carryover preserved. On splits,
disallowed_loss_carryoverstays on the lot and folds into the new per-share basis viacost_basis_per_share. On stock mergers and spin-offs, the carryover is absorbed into the newacquired_price.
Each handler has tests that assert these invariants on specific scenarios. See tests/test_l2_corp_actions.py.
Worked examples
Forward split
# 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
# 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
# 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
# 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
# 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
# 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
# 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:
# 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
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
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.Mixedis in the enum for future use; v1 handlesCashandStockonly. - 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_dividendfor 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.kindis a signal; the 1099-DIV preparation is downstream. - Data source integration. This module accepts
CorporateActionobjects you construct. Polygon, IBKR, Refinitiv, and many other providers publish corp-action feeds; wiring one is a per-deployment task.
Related
- Tax lots describes lot mechanics and wash sales.
- Tax packets consumes gain accruals and fill history.
- Audit trail stores every action.