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:
- 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.
- 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
from horizon.compliance import (
Restriction,
RestrictionDecision,
RestrictionReason,
RestrictionScope,
RestrictionSeverity,
RestrictionsEngine,
RestrictionsGate,
)
Scopes
| Scope | scope_id interpretation |
|---|---|
RestrictionScope.Firm | Matches every account. scope_id ignored. |
RestrictionScope.Household | Account.household_id |
RestrictionScope.Client | Account.client_id |
RestrictionScope.Account | Account.account_id |
Severity
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", …), ormarket_id="*"to match every instrument (e.g. halt all trading for an account under investigation), orissuer(any market that resolves to this issuer). The gate auto-extracts the issuer fromOPT:<SYMBOL>:...market ids and fromOrderAction.metadata["issuer"].
Adding restrictions
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
# 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: thereasonstrings from every matching rule (deduped).
Risk-engine integration
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
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
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
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
Warnseverity + a human-in-the-loop observer onRestrictionCheckevents. - Position-based restrictions. Rules like “no position can exceed 5% of AUM” belong in Bucket limits. This module is for issuer-level blocks.
Related
- Suitability for the paper trail of rationale on orders that pass.
- Risk layers for the order of checks.
- Audit trail for event persistence.