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

python
from horizon.compliance import (
    PreClearanceGate,
    PreClearanceRule,
    PreClearanceQueue,
    SQLitePreClearanceQueue,
    InMemoryPreClearanceQueue,
    PendingRequest,
    PreClearanceStatus,
    approve_preclearance,
    reject_preclearance,
    submit_approved_preclearance,
)

Shape of a rule

python
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

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

python
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

ClassUse
SQLitePreClearanceQueueProduction. Approvals survive process restarts. Terminal-status trigger prevents a decided request from being reversed.
InMemoryPreClearanceQueueTests 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):

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

python
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

WhenCategorySeverity
gate.check() queues a requestRestrictionCheckNotice
approve_preclearance succeedsRestrictionCheckNotice
reject_preclearance succeedsRestrictionCheckNotice
submit_approved_preclearance submitsAnnotationInfo
Policy-denied approve / rejectAccessDeniedWarning

Each event carries the request_id, reason, market_id, account_id, and decided_by. Re-playable from the audit log alone.

Related