Pre-clearance workflow
Human-in-the-loop approval gate for sensitive orders. Watchlist names, insider-adjacent positions, large notional trades, client special instructions.
Some orders cannot be executed automatically. Watchlist names, insider windows, thinly-traded issues, trades above a client-specific notional threshold — the firm’s policy says a compliance officer must review each one before it reaches the broker.
horizon.compliance.preclearance adds a queue + approve / reject flow between the risk engine and the venue. A matched order is held, persisted, surfaced to compliance, and only submitted after an explicit human decision.
Import
from horizon.compliance import (
PreClearanceGate,
PreClearanceRule,
PreClearanceQueue,
SQLitePreClearanceQueue,
InMemoryPreClearanceQueue,
PendingRequest,
PreClearanceStatus,
approve_preclearance,
reject_preclearance,
submit_approved_preclearance,
)
Shape of a rule
PreClearanceRule(
reason="watchlist:IPO-restricted", # what gets stored in the request
market_id="NVDA", # scope: a specific market
account_id=None, # scope: a specific account
client_id=None, # scope: a specific client
min_notional_usd=250_000.0, # only fire above this size
)
Any non-None field narrows the match; all set fields must match. A rule with only a reason and min_notional_usd fires on any order above that size.
The gate
gate = PreClearanceGate(
queue=SQLitePreClearanceQueue(path="./preclearance.db"),
rules=(
PreClearanceRule(reason="watchlist", market_id="NVDA"),
PreClearanceRule(reason="large-trade", min_notional_usd=1_000_000),
),
audit_log=audit_log,
)
decision = gate.check(action, account=account)
if decision.is_reject and decision.layer == "preclearance":
# Held. The request is in the queue; compliance reviews it.
continue
A matched order does not reach the venue. The gate returns Decision.reject(layer="preclearance") so the caller can distinguish a hold from a final rejection.
Automatic queueing from restrictions
When you pass the restrictions engine, any RestrictionSeverity.Warn match (watchlist-style) is treated as a pre-clearance trigger automatically:
decision = gate.check(action, account=account, restrictions_engine=restrictions)
Block-severity restrictions continue to hard-reject via RestrictionsGate and never reach pre-clearance.
Queue backends
| Class | Use |
|---|---|
SQLitePreClearanceQueue | Production. Approvals survive process restarts. Terminal-status trigger prevents a decided request from being reversed. |
InMemoryPreClearanceQueue | Tests only. |
Both implement the PreClearanceQueue Protocol (enqueue, list_pending, list_all, get, decide, close).
Approve / reject
Compliance decides via the helpers (or a future CLI):
from horizon.compliance import (
approve_preclearance,
reject_preclearance,
submit_approved_preclearance,
)
approved = approve_preclearance(
queue, request_id="prc_abc123",
decided_by="alice",
note="MNPI window cleared 2026-04-15",
audit_log=audit_log,
)
# Later, push the approved order to the venue.
ok, detail = submit_approved_preclearance(
queue, request_id="prc_abc123", venue=alpaca_venue,
audit_log=audit_log,
)
submit_approved_preclearance deserializes the stored OrderAction and submits through venue.submit; on failure it returns (False, error_text) and the request stays in Approved so compliance can retry.
RBAC integration
Pass a Principal and a Policy and the approve / reject helpers enforce Permission.PreclearanceApprove / Permission.PreclearanceReject at the call site:
from horizon.auth import Policy, Principal, Role
officer = Principal.create("alice", Role.ComplianceOfficer)
pol = Policy(audit_log=audit_log)
approve_preclearance(
queue, request_id="prc_abc123",
decided_by="alice",
principal=officer, policy=pol,
audit_log=audit_log,
)
A trader trying to approve a pending request hits PermissionDenied; the denial is recorded in the audit log as AuditCategory.AccessDenied. See RBAC for details.
Without policy=, no authz check runs — the hobby / single-operator flow still works.
Audit events
| When | Category | Severity |
|---|---|---|
gate.check() queues a request | RestrictionCheck | Notice |
approve_preclearance succeeds | RestrictionCheck | Notice |
reject_preclearance succeeds | RestrictionCheck | Notice |
submit_approved_preclearance submits | Annotation | Info |
| Policy-denied approve / reject | AccessDenied | Warning |
Each event carries the request_id, reason, market_id, account_id, and decided_by. Re-playable from the audit log alone.
Related
- Restrictions engine — Block vs. Warn severity; Warn auto-queues into pre-clearance.
- RBAC — who can approve.
- Audit trail.