Per-Order Checks

The 8-stage pre-trade pipeline that runs before every order

Every OrderAction the executor emits goes through the per-order check pipeline before the venue sees it. The pipeline catches NaN / Inf, price range violations, notional cap breaches, rate limit overruns, and drawdown-halt violations.

The 8 stages

Kill switch check

If the kill switch is engaged (from a prior drawdown event), every order is rejected.

Drawdown halt check

If a drawdown guard is firing with action="halt_new", new-opening orders are rejected. Reducing orders are allowed.

NaN / Inf sanity

Quantity and price are checked for NaN, Inf, and non-positive values. Garbage-in is rejected at the boundary.

Price range

per_order.price_min ≤ price ≤ per_order.price_max. Default is (0.01, 100_000). Catches fat-finger errors.

Notional cap

quantity × price ≤ per_order.max_order_notional_usd. Optional shrink-to-fit behavior when shrink_to_fit=True.

Rate limit

Sliding-window count of orders over the last second. If > per_order.rate_limit_per_sec, reject.

Max orders per tick

If more than per_order.max_orders_per_tick orders have already been accepted this tick, reject.

Stop-loss cooldown

If the market is in cooldown after a recent stop fire, reject new orders on it until the cooldown expires.

Configuration

python
from horizon.risk import OrderRisk

per_order = OrderRisk(
    max_order_notional_usd=25_000,
    max_size_pct_of_adv=0.01,
    price_min=0.01,
    price_max=100_000.0,
    rate_limit_per_sec=10.0,
    dedup_window_ms=500,
    allow_market_orders=True,
    max_orders_per_tick=20,
    shrink_to_fit=False,
)
max_order_notional_usdfloat
Single-order cap. Orders exceeding this are either rejected (default) or shrunk to fit (when `shrink_to_fit=True`).
max_size_pct_of_advfloat
Max size as a fraction of average daily volume. Currently informational. enforcement requires a live ADV feed.
price_minfloat
Minimum valid price. Rejects orders priced below this.
price_maxfloat
Maximum valid price. Catches fat-finger errors like "sell at 1_000_000".
rate_limit_per_secfloat
Max orders per second. Sliding-window count. Rejects the (N+1)th order in a 1-second window.
dedup_window_msint
Dedup window for identical orders. Currently informational. used by stateful risk checks.
allow_market_ordersbool
When False, market orders are rejected. Useful for conservative mandates that want only limit orders.
max_orders_per_tickint
Cap on orders accepted per tick. Prevents runaway behavior from a broken strategy.
shrink_to_fitbool
When True, notional cap violations cause the order to be **resized** (not rejected). The executor gets a `Decision.resize(new_quantity=...)` which it applies before submission.

Decision types

python
from horizon.types import Decision, DecisionKind

class DecisionKind(str, Enum):
    Pass = "pass"
    Reject = "reject"
    Resize = "resize"

The per-order check returns one of:

python
Decision.pass_()                           # approved, submit as-is
Decision.reject("reason string")           # blocked
Decision.resize(new_quantity, reason)      # approved at reduced size

Example: shrink-to-fit

python
from horizon.risk import OrderRisk

# Reject the order entirely if notional > $500
cfg1 = OrderRisk(max_order_notional_usd=500, shrink_to_fit=False)

# Shrink to 500 notional if notional > $500
cfg2 = OrderRisk(max_order_notional_usd=500, shrink_to_fit=True)

With cfg1:

python
# Order: 10 shares @ $100 = $1,000 notional
# Cap: $500
# Result: rejected

With cfg2:

python
# Order: 10 shares @ $100 = $1,000 notional
# Cap: $500
# Result: resized to 5 shares @ $100 = $500 notional

Shrink-to-fit is useful when you trust the strategy’s direction but want to bound single-order risk.

Rate limiting

python
cfg = OrderRisk(rate_limit_per_sec=3)

Within any 1-second window, max 3 orders. The 4th is rejected:

python
# t=0.0: place order 1 → pass
# t=0.1: place order 2 → pass
# t=0.2: place order 3 → pass
# t=0.3: place order 4 → reject "rate limit exceeded"
# t=1.1: place order 5 → pass (old orders expired from window)

The sliding window is precise. orders are added to a deque with their timestamps, and _prune_rate_window removes expired entries before each check.

Drawdown halt interaction

When a drawdown guard is active, the pre-order check reads state.drawdown_halt_active and:

  • If "halt_new": reject opening orders, allow reductions
  • If "flatten": reject everything (only forced-close events from the watchdog pass)

Reduction detection:

python
def _is_opening(self, action: OrderAction, state: RiskEngineState) -> bool:
    if state.ledger is None:
        return True
    pos = state.ledger.position(action.market_id)
    if pos is None:
        return True
    if action.side == "buy":
        return pos.quantity >= 0   # buying while long or flat = opening
    return pos.quantity <= 0       # selling while short or flat = opening

So “buy AAPL” while you have 10 long AAPL is an opening order (adding to the position), while “sell AAPL” would be a reducing order.

Stop-loss cooldown

After a stop loss fires on a market, that market enters a cooldown window. New orders on it are rejected:

python
cooldown_until = self._stop_loss_cooldown.get(action.market_id)
if cooldown_until is not None and state.now < cooldown_until:
    return Decision.reject(f"market in stop-loss cooldown until {cooldown_until}")

The cooldown duration comes from StopLoss.cooldown. Default is no cooldown (zero duration), so stops fire without re-entry protection. Set to timedelta(hours=1) or similar for production.

Tests

python
# tests/test_risk_engine.py::TestPreOrder

def test_valid_order_passes(self) -> None:
    engine = RiskEngine(RiskConfig())
    d = engine.check_order(_action(), _state())
    assert d.passed

def test_nan_quantity_rejected(self) -> None:
    engine = RiskEngine(RiskConfig())
    a = OrderAction.place(market_id="A", side="buy", quantity=float("nan"), price=100)
    d = engine.check_order(a, _state())
    assert not d.passed

def test_rate_limit(self) -> None:
    cfg = RiskConfig(per_order=OrderRisk(rate_limit_per_sec=3))
    engine = RiskEngine(cfg)
    state = _state()
    for _ in range(3):
        assert engine.check_order(_action(), state).passed
    # 4th order rejected
    assert not engine.check_order(_action(), state).passed

11 tests across per-order behavior.

Next