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
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
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
OrderAction before risk checks, so IPS, PDT, and locate gates resolve per-account policy.Builds a LotBook per account
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
Runs the PDT counter and optional locates gate
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
Runs wash-sale detection at end of run
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:
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:
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:
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):
result = hz.run(..., detect_wash_sales=False)
Reading the audit log
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:
- Kill switch and drawdown halts (see Kill switch, Drawdown guards).
- Stop losses per position and trailing (see Stop losses).
- Circuit breakers (see Circuit breakers).
- Margin watchdog (see Margin).
- Scenario gates (see Scenarios).
- Per-order caps (see Per order).
The professional layers run in addition to these, not instead of them.
Migration from single-portfolio
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
| Capability | Status |
|---|---|
AuditedVenue wrapping the default venue | Automatic |
Per-account LotBook with tax_lot_election | Automatic |
LotBook populated from fills | Automatic |
PDTCounter auto-fed from drained fills | Automatic |
PDTGate checked before every risk decision for margin accounts | Automatic |
LocatesGate runs before risk checks when locates= is passed | Automatic |
IPS, NBBO, buckets, buying-power layers in RiskEngine.check_order | Automatic |
RiskDecision audit event per verdict | Automatic |
End-of-run WashSaleDetector adjustments and audit events | Automatic |
BacktestResult.lot_books, wash_sale_adjustments, day_trades | Populated |
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.
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.
result = hz.run(
mode="live",
feed=my_live_feed,
strategies=[MyStrategy],
accounts=registry,
audit_log=audit_log,
max_duration_s=3600,
)
Status
| Capability | Status |
|---|---|
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 loop | Manual via VenueLedgerReconciler |
| Crash recovery across restarts | hz.run(recover_from=audit_log). See Recovery. |
| Live feed staleness with halt and optional auto-flatten | horizon.ops.LiveWatchdog. See Watchdog. |
| Reject-streak, intraday-loss, min-equity halts | horizon.ops.LiveWatchdog. |
| Session-aware halt with flatten-before-close | LiveWatchdogConfig.session_calendar. |
| Best-execution report | horizon.reporting.BestExReportBuilder. See Best execution. |
| Client statement | horizon.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-reds | L2 |
| Per-account strategies | L2 (single strategy shared across accounts today) |