Running in professional mode

hz.run() with accounts, audit log, and the full set of professional risk layers.

Everything the Professionals section defines (accounts with IPS, tax lots, audit trail, risk layers) plugs into hz.run() through two keyword arguments: accounts= and audit_log=. With both present, the same backtest loop runs as an account-aware, auditable execution.

The call

python
import horizon as hz
from horizon.accounts import (
    Account, AccountRegistry, AccountSubType, Custodian,
    InvestmentPolicyStatement,
)
from horizon.asset_classes import AssetClass
from horizon.audit import AuditLog, SQLiteSink
from horizon.risk.buckets import BucketLimits
from horizon.risk.config import RiskConfig

# One-time setup (do at process start)
custodian = Custodian(
    custodian_id="cust_schwab", display_name="Schwab Advisor Services",
    venue_name="schwab",
)
ips = InvestmentPolicyStatement(
    allowed_asset_classes=frozenset([AssetClass.Equity]),
    excluded_tickers=frozenset({"TSLA"}),
    max_pct_single_issuer=0.10,
    allow_shorts=False,
    allow_margin=False,
    max_drawdown_pct=0.20,
)
account = Account(
    account_id="acc_jane_taxable",
    sub_type=AccountSubType.SMA,
    display_name="Jane Taxable",
    client_id="cli_jane",
    custodian_id="cust_schwab",
    venue_name="schwab",
    ips=ips,
    asset_class_allowlist=frozenset([AssetClass.Equity]),
)
registry = AccountRegistry()
registry.add_custodian(custodian)
registry.add_account(account)

audit_log = AuditLog(sink=SQLiteSink("audit.db"))

risk = RiskConfig()
risk.buckets = BucketLimits(
    max_notional_per_issuer_usd=50_000,
    max_pct_per_sector=0.30,
    max_size_pct_of_adv=0.01,
)
risk.per_order.max_price_band_bps = 500
risk.per_order.max_spread_bps = 500
risk.per_order.max_feed_age_seconds = 15

result = hz.run(
    mode="backtest",
    strategies=[MyStrategy],
    asset_classes=[AssetClass.Equity],
    universe=["AAPL", "MSFT", "NVDA"],
    risk=risk,
    accounts=registry,
    audit_log=audit_log,
)

What changes vs. single-portfolio mode

When accounts= and audit_log= are set, the run loop:

Wraps every venue in AuditedVenue

Each venue in venues= (or the default Paper venue) is wrapped so every submit, acknowledge, fill, cancel, and reject becomes a chain-linked audit event. Wrapping is transparent: Paper.tick() and other venue-specific methods work through attribute forwarding.

Tags every order with an account_id

The active account's id is stamped on every OrderAction before risk checks, so IPS, PDT, and locate gates resolve per-account policy.

Builds a LotBook per account

A LotBook is created per account using its tax_lot_election (FIFO / LIFO / HIFO / SpecID / AverageCost). Every drained fill is routed into the right book: open_lot for opening exposure, close_quantity for closes. See Tax lots.

Runs the professional risk pipeline

IPS gate (asset class, excluded tickers, sector, short, options, crypto, prediction, margin), NBBO fat-finger and stale-quote guards, bucket concentration caps (issuer, sector, cluster, ADV), live buying-power. See Risk layers.

Runs the PDT counter and optional locates gate

Every fill feeds the shared PDTCounter. PDTGate rejects new orders when a margin account is at or over the 3-day-trade threshold with equity below $25k. If locates= is passed with any LocateProvider, LocatesGate enforces Reg SHO before shorts.

Emits a RiskDecision audit event for every verdict

Pass, Resize, or Reject, each produces a chain-linked event with the layer that fired, order details, account_id, and a correlation id. The hash chain is verifiable end to end. See Audit trail.

Runs wash-sale detection at end of run

After the last tick, WashSaleDetector walks each account's lot book, flags IRS §1091 patterns, applies basis adjustments to replacement lots, and emits WashSaleDetected events. Adjustments appear on BacktestResult.wash_sale_adjustments.

Without accounts=, the engine’s state has account_registry=None and every professional-tier check short-circuits to Pass. Single-portfolio flows are unchanged.

Pulling tax lots from a run

After hz.run() returns, BacktestResult carries every account’s LotBook:

python
result = hz.run(..., accounts=registry, audit_log=audit_log)

for account_id, book in result.lot_books.items():
    print(f"Account {account_id}")
    print(f"  Open lots: {len(book.open_lots())}")
    print(f"  Realized P&L: ${book.realized_pnl():,.2f}")

for adj in result.wash_sale_adjustments:
    print(f"  wash sale: ${adj.disallowed_loss:,.2f} "
          f"carried from {adj.closed_lot_id} -> {adj.replacement_lot_id}")

for account_id, trades in result.day_trades.items():
    print(f"Account {account_id}: {len(trades)} day trades")

Each LotBook has the full cost-basis history a 1099-B needs:

python
for lot in book.closed_lots():
    print(f"  {lot.market_id}: {lot.quantity} @ ${lot.acquired_price:.2f} "
          f"-> ${lot.closed_price:.2f}, realized ${lot.realized_pnl:.2f}, "
          f"long-term: {lot.is_long_term}, wash: {lot.was_wash_sale}")

Reg SHO locates

Pass a LocateProvider to enforce short-locate requirements:

python
from horizon.execution.locates import EasyToBorrowListProvider

etb = EasyToBorrowListProvider(
    etb={"AAPL", "MSFT", "GOOG", "AMZN"},   # daily firm ETB list
    default_borrow_fee_bps=25.0,
)

result = hz.run(
    ...,
    accounts=registry,
    audit_log=audit_log,
    locates=etb,
)

Short orders on ETB names pass without a round-trip. Off-list shorts trigger request_locate. Approved or denied outcomes emit LocateApproved or LocateDenied audit events. For approvals, the locate id and borrow fee are stamped on the order’s metadata so the venue can echo them to the broker.

To disable wash-sale detection (for example, in research runs with heavy turnover):

python
result = hz.run(..., detect_wash_sales=False)

Reading the audit log

python
from horizon.audit import AuditCategory, AuditChain

events = list(audit_log._sink.read_range())

# Verify the hash chain is intact
broken = AuditChain.verify(events)
assert broken == [], f"chain corrupted at {broken}"

passes = [e for e in events if e.category == AuditCategory.RiskDecision
          and e.payload.get("kind") == "pass"]
rejects = [e for e in events if e.category == AuditCategory.RiskDecision
           and e.payload.get("kind") == "reject"]

from collections import Counter
by_layer = Counter(e.payload.get("layer") for e in rejects)
# Counter({'ips': 8, 'buckets': 12, 'nbbo': 3, 'buying_power': 1})

ips_rejects = [e for e in rejects if e.payload.get("layer") == "ips"]
for e in ips_rejects[:3]:
    print(e.timestamp, e.market_id, e.message)

Every event carries account_id, market_id, client_order_id, and a correlation_id that threads related events (submit, acknowledge, fill). Queries by account / market / time range are direct SQL against the SQLiteSink database.

Core 7 layers still run

The core risk layers from Risk overview still apply:

The professional layers run in addition to these, not instead of them.

Migration from single-portfolio

diff
  result = hz.run(
      mode="backtest",
      strategies=[MyStrategy],
      universe=[...],
+     accounts=registry,
+     audit_log=audit_log,
  )

The account’s IPS becomes the source of truth for per-order policy. The audit log captures every decision.

What is automatic when accounts= / audit_log= / locates= is passed

CapabilityStatus
AuditedVenue wrapping the default venueAutomatic
Per-account LotBook with tax_lot_electionAutomatic
LotBook populated from fillsAutomatic
PDTCounter auto-fed from drained fillsAutomatic
PDTGate checked before every risk decision for margin accountsAutomatic
LocatesGate runs before risk checks when locates= is passedAutomatic
IPS, NBBO, buckets, buying-power layers in RiskEngine.check_orderAutomatic
RiskDecision audit event per verdictAutomatic
End-of-run WashSaleDetector adjustments and audit eventsAutomatic
BacktestResult.lot_books, wash_sale_adjustments, day_tradesPopulated

Block allocation across accounts

When the registry has 2 or more accounts, hz.run() switches to the block-allocation flow: one parent order aggregates across accounts, per-account gates run independently, fills split via ProRataAllocator (default) or a custom allocator=. See Allocation.

python
from horizon.execution.allocation import RotationalAllocator

result = hz.run(
    ...,
    accounts=registry,              # 2 or more accounts
    allocator=RotationalAllocator(),
    audit_log=audit_log,
)

Live mode

hz.run(mode="live", feed=...) uses the same _process_tick as the backtest, so every gate, audit event, and allocator works identically. See Live mode.

python
result = hz.run(
    mode="live",
    feed=my_live_feed,
    strategies=[MyStrategy],
    accounts=registry,
    audit_log=audit_log,
    max_duration_s=3600,
)

Status

CapabilityStatus
Real broker LiveFeed adapters (Alpaca WS shipped, IBKR / Hyperliquid / Polymarket pending)Alpaca: shipped. Others: L1.
Real broker Venue.submit() (Alpaca REST shipped)Alpaca: shipped. Others: L1.
Live fills reconciliation loopManual via VenueLedgerReconciler
Crash recovery across restartshz.run(recover_from=audit_log). See Recovery.
Live feed staleness with halt and optional auto-flattenhorizon.ops.LiveWatchdog. See Watchdog.
Reject-streak, intraday-loss, min-equity haltshorizon.ops.LiveWatchdog.
Session-aware halt with flatten-before-closeLiveWatchdogConfig.session_calendar.
Best-execution reporthorizon.reporting.BestExReportBuilder. See Best execution.
Client statementhorizon.reporting.StatementBuilder (text + JSON; HTML/PDF in L2). See Statements.
Performance measurement (TWR / MWR / drawdown / Sharpe)horizon.reporting.PerformanceBuilder. See Performance.
Tax packet (1099-B with wash-sale carryover)horizon.reporting.TaxPacketBuilder. See Tax packets.
Management and performance fee accrual (HWM, hurdle, daily average)horizon.accounting.accrue_period_fees. See Fees.
Fund NAV, units, subs-redsL2
Per-account strategiesL2 (single strategy shared across accounts today)