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

text
 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.

python
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:

LayerCheckConfigReference
1. Pre-orderNaN/Inf, notional caps, rate limits, price boundsRiskConfig.per_orderPer order
2. Stop lossesForced close on per-position lossRiskConfig.stop_lossStop losses
3. Drawdown guardsHalt-new / reduce / flatten at portfolio DDRiskConfig.drawdownDrawdown guards
4. Circuit breakersVol spike, correlation shock, reject streakRiskConfig.breakersCircuit breakers
5. Margin watchdogWarn / reduce / emergency on margin utilizationRiskConfig.marginMargin
6. Scenario gatesForward tail-risk via user hooksRiskConfig.scenariosScenarios
7. Kill switchGlobal halt on DD trigger or operator fireRiskConfig.kill_switchKill 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.

python
OrderRisk(max_feed_age_seconds=15.0)

Wide spread. Reject when the quoted spread exceeds max_spread_bps. Configurable per asset class via AssetRisk.

python
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.

python
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_allowlist and IPS allowed_asset_classes must both permit.
  • Excluded tickers. Firm-wide and per-client hard blocks.
  • Excluded sectors. Matches Market.sector against IPS esg_screens.
  • Short-sell. Reject if opening or increasing a short and allow_shorts=False.
  • Options. Reject asset class Option when allow_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=False and the order requires margin (short open or flagged asset classes). Authoritative margin enforcement runs in the live buying-power check.
python
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:

python
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.underlying roll 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"] or RiskEngineState.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.

python
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:

python
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 LocateProvider Protocol. 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.