Risk layers
The core 7-layer pipeline plus professional additions: IPS gate, NBBO fat-finger, stale-quote, bucket limits.
The risk engine runs a linear pipeline on every order. Earlier layers are cheap and block the obvious errors. Later layers add context. This page covers the core 7 layers from Risk overview, plus the professional additions activated by accounts and live feeds.
Order in RiskEngine.check_order
1. Kill switch global halt
2. Drawdown halt portfolio-level guard
3. Sanity (NaN/Inf, qty > 0) reject garbage
4. Price bounds per-order config
5. Notional cap + shrink-to-fit per-order config
6. Rate limit orders / second
7. Orders-per-tick cap prevent runaway loop
8. Market orders allowed? config toggle
9. Stop-loss cooldown for market prevents re-entering after a stop
Professional additions:
10. NBBO fat-finger + stale-quote reject limits through NBBO, reject on stale feed
11. IPS gate per-account asset / sector / short / options policy
12. Bucket limits per-issuer, per-sector, per-cluster, ADV
13. Live buying-power per-venue free BP vs. projected notional
Standalone gates (runnable outside the engine):
LocatesGate (Reg SHO) ETB or successful locate for short opens
PDTGate (FINRA 4210) cap day trades when equity below $25k
A Decision returned from any layer short-circuits the rest. Every Decision.reject(...) carries a layer field so audit events identify which check fired. See Decision attribution.
Decision.reject("max_order_notional exceeded", layer="per_order")
Decision.reject("IPS blocks asset class option", layer="ips")
Decision.reject("limit priced 1100bps off NBBO mid", layer="nbbo")
Core 7 layers
See Risk overview and linked pages:
| Layer | Check | Config | Reference |
|---|---|---|---|
| 1. Pre-order | NaN/Inf, notional caps, rate limits, price bounds | RiskConfig.per_order | Per order |
| 2. Stop losses | Forced close on per-position loss | RiskConfig.stop_loss | Stop losses |
| 3. Drawdown guards | Halt-new / reduce / flatten at portfolio DD | RiskConfig.drawdown | Drawdown guards |
| 4. Circuit breakers | Vol spike, correlation shock, reject streak | RiskConfig.breakers | Circuit breakers |
| 5. Margin watchdog | Warn / reduce / emergency on margin utilization | RiskConfig.margin | Margin |
| 6. Scenario gates | Forward tail-risk via user hooks | RiskConfig.scenarios | Scenarios |
| 7. Kill switch | Global halt on DD trigger or operator fire | RiskConfig.kill_switch | Kill switch |
The professional tier adds to these; it does not change them.
Professional additions
NBBO fat-finger and stale-quote
Activates when RiskEngineState.recent_feeds carries a FeedData for the order’s market. No feed makes the check a no-op.
Stale quote. Reject when the most recent tick is older than max_feed_age_seconds.
OrderRisk(max_feed_age_seconds=15.0)
Wide spread. Reject when the quoted spread exceeds max_spread_bps. Configurable per asset class via AssetRisk.
OrderRisk(max_spread_bps=200.0) # 2%
Price band. For limit and stop_limit orders, reject when price is more than max_price_band_bps off the NBBO mid. Market orders skip this check.
OrderRisk(max_price_band_bps=500.0) # 5%
Set any threshold to 0 to disable that sub-check.
IPS gate
Activates when RiskEngineState.account_registry is provided and OrderAction.account_id matches a registered account. Enforces fields from InvestmentPolicyStatement:
- Asset-class allowlist. Account
asset_class_allowlistand IPSallowed_asset_classesmust both permit. - Excluded tickers. Firm-wide and per-client hard blocks.
- Excluded sectors. Matches
Market.sectoragainst IPSesg_screens. - Short-sell. Reject if opening or increasing a short and
allow_shorts=False. - Options. Reject asset class
Optionwhenallow_options=False. Reject when order’s required level exceeds approved level (metadata["required_options_level"]). - Crypto and prediction. Flag per asset class.
- Margin. Reject when
allow_margin=Falseand the order requires margin (short open or flagged asset classes). Authoritative margin enforcement runs in the live buying-power check.
from horizon.accounts import (
Account, AccountRegistry, InvestmentPolicyStatement, Custodian,
)
from horizon.asset_classes import AssetClass
ips = InvestmentPolicyStatement(
allowed_asset_classes=frozenset([AssetClass.Equity]),
max_pct_single_issuer=0.10,
excluded_tickers=frozenset({"TSLA"}),
esg_screens=frozenset({"no_tobacco"}),
allow_margin=False,
allow_shorts=False,
allow_options=False,
max_drawdown_pct=0.20,
)
account = Account(
account_id="acc_jane_taxable",
sub_type=AccountSubType.SMA,
display_name="Jane Taxable",
ips=ips,
asset_class_allowlist=frozenset([AssetClass.Equity]),
client_id="cli_jane",
custodian_id="cust_schwab",
venue_name="schwab",
)
registry = AccountRegistry()
registry.add_custodian(Custodian(custodian_id="cust_schwab", ..., venue_name="schwab"))
registry.add_account(account)
state = RiskEngineState(
now=now, equity=eq, peak_equity=peak,
ledger=ledger,
recent_feeds=feeds, # enables NBBO
account_registry=registry, # enables IPS
markets_by_id=markets_by_id, # asset-class lookup
)
decision = engine.check_order(action, state)
Audit attribution
Every rejection records the layer:
decision = engine.check_order(action, state)
if not decision.passed:
audit_log.record(
AuditCategory.RiskDecision,
severity=AuditSeverity.Warning,
account_id=action.account_id,
order_id=None,
client_order_id=action.client_order_id,
market_id=action.market_id,
message=f"{decision.layer}: {decision.reason}",
payload={
"layer": decision.layer,
"reason": decision.reason,
"kind": decision.kind.value,
"resized_quantity": decision.resized_quantity,
},
)
See Audit trail.
Bucket concentration (BucketsGate)
Activates when risk.buckets = BucketLimits(...). Four caps, evaluated against projected post-fill exposure:
- Per-issuer. Markets sharing
Market.underlyingroll up (AAPL stock + AAPL calls = one issuer). - Per-sector. Driven by
Market.sector. - Per-cluster. User-defined groups, e.g.
{"ai_chips": {"NVDA", "AMD", "ASML"}}. - ADV. Order qty / average-daily-volume. Reads
Market.metadata["avg_daily_volume"]orRiskEngineState.adv_by_market.
Each cap supports absolute USD (max_notional_per_issuer_usd) and percent-of-equity (max_pct_per_issuer). Set 0 to disable.
Reducing trades bypass the gate. Selling into an existing long never gets blocked. The check only fires on orders that add exposure.
from horizon.risk.buckets import BucketLimits
risk.buckets = BucketLimits(
max_notional_per_issuer_usd=50_000,
max_pct_per_sector=0.30,
clusters={"ai_chips": frozenset({"NVDA", "AMD", "ASML"})},
max_pct_per_cluster=0.40,
max_size_pct_of_adv=0.01, # 1% of ADV per order
)
Live buying-power check
Runs when RiskEngineState.buying_power_by_venue is populated and the order has a venue_hint (the live loop fills both). Compares projected order notional against free BP at that venue.
Rejection when over. Resize-to-fit when risk.per_order.shrink_to_fit = True:
Decision.resize(
new_quantity=5.0,
reason="shrunk to available BP $900",
layer="buying_power",
)
Standalone: LocatesGate (Reg SHO)
Runs outside check_order; the execution layer runs it immediately before venue submission so it can stamp the locate id into OrderAction.metadata. Providers:
NoopLocateProvider: approves everything. Paper only.EasyToBorrowListProvider: fast-path ETB list with optional fallback.- Custom broker adapters (Alpaca, IBKR) via the
LocateProviderProtocol. L1 follow-up.
Short opens on ETB names pass without a round-trip. Off-list names call request_locate, which returns approved or denied plus borrow fee. Both outcomes emit LocateApproved or LocateDenied audit events.
Standalone: PDTGate (FINRA 4210)
Pattern Day Trader rule: an account with 4 or more day trades in 5 business days must hold at least $25,000 at the start of any day-trading session. PDTCounter tracks round-trips from fills. PDTGate rejects a would-be-fourth day trade when equity is below the threshold.
Cash accounts (IRA, Roth, HSA, 401k) are exempted; they follow T+1 settlement rules.
Automatic wiring in hz.run()
When accounts= and audit_log= are passed to hz.run(), the IPS gate, NBBO guards, bucket limits, and BP check all run on every order. Each decision becomes a chain-linked audit event. See Running.