Order lifecycle

FIX-style order states, client_order_id idempotency, best-ex fields, TIF, tax-lot election.

The backtest venue (Paper) uses a small order state set: new | partial | filled | canceled | rejected. Live venues need more. An order is in flight the moment it is POSTed but before the broker acknowledges. If the process crashes in that window, you need to know whether to resubmit or reconcile.

OrderStatus covers every transition a real broker reports. OrderAction carries the fields live execution needs (idempotency, per-account routing, TIF, stop/trail, tax-lot election). See Venues for the Venue Protocol the states live in.

The state machine

           submit()
 New ─────────────────────► PendingNew
                                │
                broker ack      │ broker reject
                     ┌──────────┴─────────┐
                     ▼                    ▼
                 Accepted             Rejected ✕
                     │
               first fill
                     │
                     ▼
             PartiallyFilled
                     │
               final fill / cancel / expire / DFD
                     │
         ┌───────────┼────────────────┬────────────┐
         ▼           ▼                ▼            ▼
      Filled ✕   Canceled ✕        Expired ✕   DoneForDay ✕
      (checkmark = terminal)

Side branches:
    cancel() : Accepted / PartiallyFilled -> PendingCancel -> Canceled
    amend()  : Accepted / PartiallyFilled -> PendingReplace -> Replaced -> Accepted

Enum helpers:

python
from horizon.venues.base import OrderStatus

OrderStatus.PendingNew.is_in_flight    # submitted, awaiting broker ack
OrderStatus.Accepted.is_open           # can still receive fills
OrderStatus.Filled.is_terminal         # no more transitions

is_in_flight is the state crash recovery reconciles on restart: call the broker, ask “did you see this client_order_id, what is its status?”, before doing anything else.

client_order_id: idempotency

OrderAction.place() auto-generates a ULID-shaped client order id:

python
from horizon.types import OrderAction

a = OrderAction.place(market_id="AAPL", side="buy", quantity=10, price=180.0)
a.client_order_id
# 'hzn_4656a78859454d5f...'

Effects:

  • Dedup at the broker. Alpaca, IBKR, and most major brokers honor client_order_id as a dedup key. Retrying with the same id returns the original order, not a duplicate fill.
  • Crash-safe recovery. On restart, the reconciler reads the audit log, finds orders in PendingNew, and asks each broker “do you know this client_order_id?” The answer tells it whether to resubmit or mark accepted.
  • Attribution. The id threads from strategy through risk through venue into the fill record. A best-ex line carries the same id as the strategy’s decision.

Override or disable

python
a = OrderAction.place(
    market_id="AAPL", side="buy", quantity=10, price=180.0,
    client_order_id="my_legacy_id_format_12345",
)

a = OrderAction.place(
    market_id="AAPL", side="buy", quantity=10, price=180.0,
    generate_client_order_id=False,
)
a.client_order_id   # None

Extended OrderAction fields

python
from horizon.types import OrderAction, TimeInForce, TaxLotElection
from datetime import datetime

a = OrderAction.place(
    market_id="AAPL", side="buy", quantity=100, price=180.00,
    order_type="limit",

    client_order_id="hzn_custom",           # auto-generated if omitted

    account_id="acc_jane_taxable",          # required in multi-account setups
    strategy_id="bollinger_mean_rev",
    signal_id="sig_1234",                    # attribution into fills

    tif=TimeInForce.GTC,                     # enum or plain string
    expires_at=datetime(2026, 6, 30),        # required when tif=GTD

    stop_price=175.00,                       # for stop / stop_limit
    trail_pct=0.02,                          # for trailing_stop
    post_only=True,                          # maker-only hint

    allocation_key="block_2026_03_14_aapl",  # ties to ParentOrder
    parent_order_id="parent_abc",

    tax_lot_election=TaxLotElection.HIFO,    # overrides account default
)

All new fields are optional. Existing callers (hz.pipe(), hz.run()) pass none of them and keep working.

Validation

Construction is unchecked (frozen dataclass). Call .validate() at trust boundaries (CLI input, HTTP handler, CSV loader):

python
a.validate()
# raises ValueError with a reason:
#   quantity must be positive
#   side must be 'buy' or 'sell'
#   stop order requires stop_price
#   GTD order requires expires_at
#   cancel requires order_id

Strategy code and pipeline call sites skip validate(). Reserve it for inputs from outside the process.

Extended VenueOrder and VenueFill

Venue-side snapshots gained fields real brokers return. Paper ignores them (defaults). Live venues populate what the broker provides.

python
from horizon.venues.base import VenueOrder, VenueFill, NbboSnapshot, LiquidityFlag

order = VenueOrder(
    # legacy required
    id="alpaca_ord_abc", market_id="AAPL", side="buy",
    quantity=100, filled_quantity=0, price=180.00,
    order_type="limit", status="accepted",

    # identity
    client_order_id="hzn_custom",
    venue_order_id="alpaca_ord_abc",
    broker_order_id="alpaca_internal_xyz",
    parent_order_id="parent_abc",
    account_id="acc_jane_taxable",

    # economics
    avg_fill_price=180.02,
    accumulated_fees=0.50,
    accumulated_rebates=0.0,
    time_in_force="gtc",
    stop_price=None,
    post_only=True,

    # lifecycle
    accepted_at=datetime(2026, 3, 14, 14, 30, tzinfo=timezone.utc),
    last_updated_at=datetime(2026, 3, 14, 14, 30, 1, tzinfo=timezone.utc),
    expires_at=None,

    # best-ex
    exchange_of_execution="XNAS",
    nbbo_at_submit=NbboSnapshot(
        bid=179.99, ask=180.01, bid_size=500, ask_size=300,
        timestamp=datetime(2026, 3, 14, 14, 30, tzinfo=timezone.utc),
        source="alpaca_v2",
    ),
)

fill = VenueFill(
    order_id="alpaca_ord_abc",
    market_id="AAPL",
    side="buy", quantity=50, price=180.01, fee=0.25,

    execution_id="alpaca_exec_xyz",
    client_order_id="hzn_custom",
    account_id="acc_jane_taxable",

    rebate=0.05,
    fee_currency="USD",
    settlement_currency="USD",
    fx_rate=1.0,
    gross_proceeds=9000.50,
    net_proceeds=9000.25,

    exchange_of_execution="XNAS",
    liquidity=LiquidityFlag.Taker,              # Maker / Taker / Unknown
    counterparty=None,

    nbbo_at_fill=NbboSnapshot(
        bid=180.00, ask=180.01, timestamp=..., source="alpaca_v2",
    ),
)

Best-ex fields

Rule 606/605 analysis needs:

  • Venue of execution (exchange_of_execution).
  • NBBO at submit and at fill.
  • Liquidity maker / taker flag.
  • Fees and rebates per fill.
  • Fill latency (accepted_at to VenueFill.timestamp).

All fields are present. See Best execution.

Time in force

Plain strings remain accepted ("day", "gtc", "ioc"). The TimeInForce enum is available for type-checked code:

python
from horizon.types import TimeInForce

TimeInForce.Day        # expires at session close
TimeInForce.GTC        # good-til-canceled
TimeInForce.GTD        # good-til-date; set expires_at
TimeInForce.IOC        # immediate-or-cancel
TimeInForce.FOK        # fill-or-kill
TimeInForce.OPG        # market-on-open auction
TimeInForce.CLS        # market-on-close auction
TimeInForce.PostOnly   # maker-only

Tax-lot election

Per-order override of the account default. IRAs typically stay FIFO; taxable accounts may prefer HIFO for harvesting, or SpecID when the strategy names the lot.

python
from horizon.types import TaxLotElection

TaxLotElection.FIFO          # IRS default for equities
TaxLotElection.LIFO
TaxLotElection.HIFO          # highest-cost-first (tax-loss harvesting)
TaxLotElection.SpecID        # specific-identification
TaxLotElection.AverageCost   # mutual-fund convention

Wired in the ledger’s apply_fill. See Tax lots.

Decision attribution

Decision (risk verdict) carries a layer field so audit events record which of the seven defensive layers fired. See Risk overview.

python
from horizon.types import Decision

Decision.reject("exceeds single-issuer cap 0.10", layer="concentration")
Decision.resize(new_quantity=50, reason="buying power", layer="buying_power")
Decision.pass_()

Every rejection or resize is searchable by layer in the audit trail.