T+1 settlement tracking

Settled vs. traded quantity per asset class. Business-day math. Feeds cash-account buying-power and 1099-B reporting.

US equities and options settle T+1 (changed from T+2 in May 2024). Forex settles T+2. Crypto, perps, and prediction markets are effectively T+0. horizon.state.settlement tracks which trades have actually settled at any point in time.

This matters because:

  • Cash accounts. FINRA’s free-rider rule forbids using proceeds of an unsettled sale to buy another marginable security. The settled balance is what counts.
  • Reg T margin. Day-trading buying power calculations key off settled cash.
  • 1099-B. Trade date drives reporting date and holding-period; settlement date drives when cash is available.
  • End-of-year reconciliation. A trade on 2026-12-31 (Thursday) settles 2027-01-02. The wire-side cash balance differs from the trade-date balance across year-end.

The module is additive. It does not change PositionLedger or anything in the hot path.

Import

python
from horizon.state import (
    SettlementEngine,
    SettlementRule,
    SettlementState,
    SettlementStatus,
    PendingSettlement,
    DEFAULT_SETTLEMENT_RULES,
)
from horizon.asset_classes import AssetClass

Quickstart

python
from datetime import datetime, timezone
from horizon.calendars import NYSECalendar
from horizon.audit import AuditLog, SQLiteSink

engine = SettlementEngine(
    calendar=NYSECalendar(),
    audit_log=AuditLog(sink=SQLiteSink("audit.db")),
)

# Feed every fill into the engine. Typical wiring in the run loop:
for fill in venue.drain_fills():
    asset_class = markets_by_id[fill.market_id].asset_class
    engine.on_fill(fill, asset_class=asset_class, account_id=fill.account_id)

# Once a session (end of day is fine):
engine.advance_to(datetime.now(timezone.utc))

# Queries:
engine.settled_quantity("AAPL", account_id="acc_jane")   # signed
engine.unsettled_cash(account_id="acc_jane")             # USD
engine.pending(account_id="acc_jane")                    # list[PendingSettlement]
engine.state()                                            # full snapshot

Default rules

Asset classDaysBusiness days only
Equity1yes
Option1yes
Future0yes (daily MTM)
Forex2yes
Crypto0no (24/7)
Perp0no
Prediction0no

Override per asset class with a custom rule set:

python
engine = SettlementEngine(rules=(
    SettlementRule(AssetClass.Equity, 2),   # legacy T+2
    # ...
))

Business-day math

With calendar=NYSECalendar() attached, settlement skips NYSE holidays. Without a calendar (the default), only weekends are skipped.

Install the optional pandas_market_calendars (pip install horizon[equity]) for accurate US holiday detection. Without it, NYSECalendar falls back to a Mon-Fri rule and may miss holidays like MLK Day or Good Friday.

Pending vs. settled

python
p = engine.on_fill(fill, asset_class=AssetClass.Equity)
p.settle_id                       # internal id
p.trade_date                      # when the fill occurred
p.settle_date                     # start of the settle day (00:00 UTC)
p.signed_quantity                 # +qty for buys, -qty for sells
p.notional_usd                    # quantity * price
p.net_cashflow_usd                # -notional-fee on buys, +notional-fee on sells

advance_to(ts) promotes every pending entry with settle_date <= ts to Settled. It is idempotent and a no-op when going backwards.

Cash-account integration

The module answers the “can I use these proceeds?” question:

python
# A cash account holder sold AAPL today. The sale won't settle until
# tomorrow. If they try to buy MSFT using those proceeds today, that's
# free-riding.
unsettled = engine.unsettled_cash(account_id="acc_jane_cash")   # +$5,000
settled = account.cash_balance                                   # $10,000
# Available for new purchases in a cash account:
buyable = settled

Wire this into a Decision.reject("free-rider", layer="settlement") gate when action.account_id maps to a cash-only account and settled_cash < projected_notional.

The pre-built risk engine does not currently enforce this rule. Add it as a custom gate when you run cash accounts.

Failed settlement (FTD)

python
engine.mark_failed(pending.settle_id, reason="DTC fail-to-deliver")

Promotes the entry to SettlementStatus.Failed, removes from pending(), and records an AuditCategory.SettlementFailed event. The entry does not contribute to settled_quantity or settled_cash.

Reg SHO close-out obligations and FTD tracking are out of scope here; this module just lets compliance flag a specific settlement as failed and surface the audit trail.

Audit events

CategoryWhen
TradeSettledA pending entry promotes to Settled.
SettlementAdvancedadvance_to() processed pending entries.
SettlementFailedmark_failed() marked an entry as failed.

Every event carries the settle_id, execution id, account id, market id, quantity, and dates. Examiners can reproduce the settled-vs-traded state at any prior moment by replaying the log.

Snapshot

python
state = engine.state(at=datetime(2026, 12, 31, tzinfo=timezone.utc))
state.settled_quantity("AAPL", "acc_jane")
state.traded_quantity("AAPL", "acc_jane")
state.settled_cash_delta_by_account["acc_jane"]
state.unsettled_cash_delta_by_account["acc_jane"]
state.pending                          # tuple[PendingSettlement, ...]
state.to_dict()                        # JSON-safe

Use state snapshots for point-in-time reconciliation. The 2026-12-31 snapshot above answers “what was the as-of-year-end settled balance?” for tax-packet generation.

1099-B alignment

Settlement date does not change holding-period (trade date does), but it matters for:

  • Wire-side cash balance shown on the year-end statement.
  • Accrued but unsettled proceeds booked on the 1099-B.

The tax packet builder is not yet wired to the settlement engine; pass the state snapshot through to override wire-side cash totals when you need settlement-date alignment.

Reconstruction from the audit log

Re-feeding fills from the audit log on process restart rebuilds the engine state:

python
from horizon.audit import AuditCategory

engine = SettlementEngine(calendar=NYSECalendar())
for event in audit_log._sink.read_range():
    if event.category == AuditCategory.OrderFilled:
        # reconstruct a VenueFill from the event payload
        ...
        engine.on_fill(fill, asset_class=asset_class, account_id=fill.account_id)
engine.advance_to(datetime.now(timezone.utc))

The Recovery module will wire this up automatically in a follow-up; for now feed it yourself.

Out of scope

  • Per-security settle rules. All trades in an asset class use the same SettlementRule. Treasuries (T+1) and mutual funds (T+1 or T+2 depending) often share the equity rule in US markets; add a custom rule when you need finer control.
  • Fails-to-deliver obligation tracking (Reg SHO close-out). mark_failed flags; enforcement of close-out timers is a firm-policy concern.
  • International settlement cycles. Defaults are US-centric. Override rules per asset class.
  • Intra-day settlement dynamics. The engine advances at day granularity; a trade at 23:50 UTC on trade-date-1 and a trade at 00:10 UTC on trade-date share the same trade date but may differ in broker bookkeeping by microseconds.

Related