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:
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:
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_idas 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 thisclient_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
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
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):
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.
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_attoVenueFill.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:
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.
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.
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.