Restrictions engine

Firm-wide and per-client restricted-securities blocks. Insider windows, sanctions lists, client no-buy preferences.

Two kinds of restrictions matter to an advisor:

  1. Firm-wide. An insider who knows material nonpublic information must stay off the relevant issuer during the window. Banned issuers (sanctions, ex-employee holdings, risk-committee decisions) land here.
  2. Per-client or per-account. A client asks the advisor to avoid an issuer (ESG preference, conflict with their employer, personal reasons).

horizon.compliance.restrictions stores both kinds and evaluates them at order time. RestrictionsGate runs before IPS in the risk pipeline so anything in a restricted category is rejected before reaching asset-class rules.

Import

python
from horizon.compliance import (
    Restriction,
    RestrictionDecision,
    RestrictionReason,
    RestrictionScope,
    RestrictionSeverity,
    RestrictionsEngine,
    RestrictionsGate,
)

Scopes

Scopescope_id interpretation
RestrictionScope.FirmMatches every account. scope_id ignored.
RestrictionScope.HouseholdAccount.household_id
RestrictionScope.ClientAccount.client_id
RestrictionScope.AccountAccount.account_id

Severity

python
RestrictionSeverity.Block    # reject the order with layer="restrictions"
RestrictionSeverity.Warn     # allow, but emit a Notice audit event

Warn is useful for watchlists that compliance reviews daily without blocking trading.

Matching

A restriction can target by:

  • market_id ("AAPL", "OPT:AAPL:20260619:180:C", …), or
  • market_id="*" to match every instrument (e.g. halt all trading for an account under investigation), or
  • issuer (any market that resolves to this issuer). The gate auto-extracts the issuer from OPT:<SYMBOL>:... market ids and from OrderAction.metadata["issuer"].

Adding restrictions

python
engine = RestrictionsEngine()

# Firm-wide: block all TSLA trading for 30 days (sanctions, compliance pause).
engine.add_firm_block(
    market_id="TSLA",
    reason="sanctions",
    severity=RestrictionSeverity.Block,
    end_at=datetime(2026, 5, 18, tzinfo=UTC),
    added_by="compliance_officer",
    note="OFAC added issuer to sanctions list 2026-04-18",
)

# Insider window: block every AAPL security (stock + options) from
# Monday until earnings.
engine.add_firm_block(
    issuer="AAPL",
    reason=RestrictionReason.InsiderWindow.value,
    start_at=datetime(2026, 5, 6, tzinfo=UTC),
    end_at=datetime(2026, 5, 10, tzinfo=UTC),
)

# Client preference: Jane does not want to hold tobacco.
engine.add_client_preference(
    client_id="cli_jane",
    market_id="PM",
    reason=RestrictionReason.ClientPreference.value,
)

# Account-level administrative pause.
engine.add_account_block(
    account_id="acc_under_review",
    market_id="*",
    reason="compliance_pause",
)

All helpers return the stored Restriction. Use the returned restriction_id for later engine.remove(...).

Checking

python
# Direct check (no risk-engine integration)
decision = engine.check(
    account=jane_account,
    market_id="TSLA",
    at=datetime.now(timezone.utc),
)
if not decision.allowed:
    print("blocked:", decision.reasons)

RestrictionDecision carries:

  • allowed: True unless any active block matches.
  • blocking: restriction ids with severity Block.
  • warnings: restriction ids with severity Warn.
  • reasons: the reason strings from every matching rule (deduped).

Risk-engine integration

python
from horizon.compliance import RestrictionsGate

gate = RestrictionsGate(engine=engine, audit_log=audit_log)

decision = gate.check(action, account=jane_account)
if decision.kind == DecisionKind.Reject:
    continue  # do not submit

The gate always emits an AuditCategory.RestrictionCheck event (pass or fail). Examiners need evidence the check ran, not just evidence of rejections.

Severity maps to audit severity:

  • Block -> AuditSeverity.Warning (order rejected).
  • Warn -> AuditSeverity.Notice.
  • Pass (no match) -> AuditSeverity.Info.

Time windows

python
engine.add_firm_block(
    market_id="AAPL",
    start_at=datetime(2026, 5, 6, tzinfo=UTC),
    end_at=datetime(2026, 5, 10, tzinfo=UTC),
)

Outside [start_at, end_at], the restriction does not match. Useful for earnings windows, SEC filing periods, and temporary compliance pauses. Leave either bound None for open-ended start or end.

Removal

python
r = engine.add_firm_block(market_id="AAPL", reason="temp")
...
engine.remove(r.restriction_id)

Historical rules stay in the audit log via RestrictionCheck events. The engine’s in-memory store is the current policy; the audit trail is the history.

Queries

python
engine.list()                                  # every rule (active or not)
engine.get(restriction_id)                     # one rule
engine.matching(account=a, market_id="AAPL")   # every matching rule, active at now

Persistence

RestrictionsEngine is in-memory. Persist by serializing engine.list() (each Restriction.to_dict() is JSON-safe) to your firm’s compliance database and rehydrating on process start. A Postgres-backed RestrictionsSink lands in L3.

Scaling

Linear scan per check. Fine for rule counts in the thousands. Beyond that, index by (market_id, issuer) and shard per scope.

Out of scope

  • Real-time watchlist feed. Pulling insider lists from a vendor (like Compliance Science or StarCompliance) is counsel’s ops work; populate the engine from that feed on a schedule.
  • Pre-clearance workflow. Some firms require a compliance officer to approve trades in watchlist names before submission. Model that as Warn severity + a human-in-the-loop observer on RestrictionCheck events.
  • Position-based restrictions. Rules like “no position can exceed 5% of AUM” belong in Bucket limits. This module is for issuer-level blocks.

Related