Skip to content

Risk controls

Strix runs pre-trade risk checks on every order. By default they are passive: a breach records a RiskBreach event and logs a WARNING, and the order still proceeds — Strix does not sit in your order path. Set on_breach="raise" to turn the checks into a hard gate that throws StrixRiskError and records the order as REJECTED before your broker is contacted.

from strix import RiskSettings

# Passive (default): a breach emits a RiskBreach event + WARN; the order proceeds.
strix.init(risk=RiskSettings(max_qty_per_order=1000))

# Gate: a breach raises StrixRiskError, order is REJECTED, broker is never called.
strix.init(risk=RiskSettings(on_breach="raise", max_qty_per_order=1000))

How to read this page: the per-check examples below show # StrixRiskError: <reason> — that's the on_breach="raise" (gate) behaviour. Under the default on_breach="warn", the same <reason> is recorded on a RiskBreach event and the order is not blocked. Stop-loss halts (StrixHaltedError, see Stop-losses) are never demoted by on_breach — an armed circuit breaker always gates, in either mode.

v1 ships six checks. All are grouped on a RiskSettings object and passed to strix.init(...) via the risk argument (or to strix.set_risk(...) mid-session). All fields are optional — omit and the check doesn't run. A single RiskSettings instance can be reused across multiple init calls.

from strix import RiskSettings

strix.init(
    risk=RiskSettings(
        max_qty_per_order=1000,
        min_qty_per_order=1,
        max_orders=500,
        max_open_orders=20,
        max_shares_per_equity_symbol=10000,
        position_limits={"AAPL": 500, "MSFT": 1000},
    ),
)

Minimum viable risk config

You don't need to set every field. Start with the smallest config that covers your worst-case operator mistake and add more as you find real gaps. The full surface is for completeness; the ladder below is for onboarding.

Tier 1 — typo defense (no market data needed)

The minimum you should not run without:

RiskSettings(
    max_qty_per_order=1_000,
    max_orders=10_000,
)

Catches the order-of-magnitude typo (qty=10_000 instead of qty=1_000) and bounds total order traffic so a runaway control loop can't burn through unlimited submissions. Two fields, no MarketDataAdapter required, won't reject any legitimate trade in a sane strategy.

Tier 2 — concurrent-exposure cap (no market data needed)

Add per-symbol position limits and a working-orders cap:

RiskSettings(
    max_qty_per_order=1_000,
    max_orders=10_000,
    max_open_orders=20,
    position_limits={"AAPL": 500, "MSFT": 1_000},
)

max_open_orders bounds how many working orders can pile up; the position_limits projection already counts in-flight unfilled qty, so a strategy that "keeps submitting until something fills" can't sneak past the cap by spamming.

Tier 3 — continuous risk (Phase 2, needs MarketDataAdapter)

Once you have a MarketDataAdapter wired, add the stop-loss safety net:

RiskSettings(
    max_qty_per_order=1_000,
    max_orders=10_000,
    max_open_orders=20,
    position_limits={"AAPL": 500, "MSFT": 1_000},
    session_stop_loss=SessionStopLoss(
        threshold=Decimal("-5_000"),
        recovery_threshold=Decimal("-1_000"),
    ),
)

SessionStopLoss(mode="total") is the highest-leverage Phase 2 field for most strategies — one session-wide P&L floor that halts new exposure when the day goes sideways. Per-symbol CostBasedStopLoss is the next step when you need per-position bounds; it costs more cognitive load (one threshold drives every held symbol).

The tight preset in examples/python/backtest_mean_reversion/main.py touches twelve fields — it exists to trip every check in one short run, not as a template. Real algos start at Tier 1 and ratchet up with evidence.

max_qty_per_order

Reject any single order whose qty exceeds the limit.

strix.init(risk=RiskSettings(max_qty_per_order=1000))

with strix.order(symbol="AAPL", side=Side.BUY, qty=2000) as o:
    broker.submit(...)
# StrixRiskError: qty 2000 exceeds max_qty_per_order 1000

Use to bound single-order risk — a typo of qty=10000 instead of qty=1000 shouldn't make it to the venue.

min_qty_per_order

Reject any single order whose qty is strictly less than the limit. The exact value passes.

strix.init(risk=RiskSettings(min_qty_per_order=10))

with strix.order(symbol="AAPL", side=Side.BUY, qty=5):
    broker.submit(...)
# StrixRiskError: qty 5 is below min_qty_per_order 10

Use to enforce venue minimum lot sizes or to filter dust-sized orders that come from rounding bugs in size-calculation code.

max_orders

Reject if the session has already attempted this many orders. Counts every entry to strix.order(...), including orders that were rejected by other checks or by user-code exceptions. This is true rate-limit semantics — a runaway loop can't burn through unlimited submissions by being repeatedly rejected.

strix.init(risk=RiskSettings(max_orders=100))
# ... 100 attempts later (any mix of accepted + rejected) ...
with strix.order(symbol="AAPL", side=Side.BUY, qty=10):
    broker.submit(...)
# StrixRiskError: max_orders 100 reached for this session

The counter is rebuilt from the event log on strix.resume, so it survives crashes.

max_open_orders

Reject any order that would push the count of open orders past the limit. "Open" means PENDING_NEW, NEW, PARTIALLY_FILLED, or PENDING_CANCEL (broker still has it). Terminal orders (REJECTED, FILLED, CANCELLED) don't count.

strix.init(risk=RiskSettings(max_open_orders=2))

with strix.order(symbol="AAPL", side=Side.BUY, qty=100):
    broker.submit(...)   # open count = 1
with strix.order(symbol="MSFT", side=Side.BUY, qty=100):
    broker.submit(...)   # open count = 2
with strix.order(symbol="GOOG", side=Side.BUY, qty=100):
    broker.submit(...)   # StrixRiskError: max_open_orders 2 reached (currently 2 open)

Use to bound concurrent broker-side exposure — strategies that "keep submitting until something fills" can pile up working orders if a venue is slow to reject.

max_shares_per_equity_symbol and position_limits

Both control the size of the projected position after a full fill. The checks are direction-agnostic — limit=500 allows long 500 or short 500, not both simultaneously.

  • max_shares_per_equity_symbol is a single global cap that applies to every symbol with no explicit override.
  • position_limits is a per-symbol map of explicit overrides. When a symbol is in the map, its value is used and the global cap is ignored for that symbol.
strix.init(
    risk=RiskSettings(
        max_shares_per_equity_symbol=1000,    # global default
        position_limits={"AAPL": 5000},       # explicit override for AAPL
    ),
)

# AAPL uses the override (5000).
with strix.order(symbol="AAPL", side=Side.BUY, qty=4000):
    broker.submit(...)

# MSFT falls back to the global cap (1000).
with strix.order(symbol="MSFT", side=Side.BUY, qty=4000):
    broker.submit(...)
# StrixRiskError: projected position 4000 for MSFT exceeds limit 1000

If neither is set for a symbol, that symbol has no position cap.

The projection formula

projected = abs(current_qty + sum(unfilled_signed_qty for o in open_orders if o.symbol == sym) + signed_delta)

where signed_delta = +order.qty for BUY, -order.qty for SELL, and each open order's unfilled remainder (qty - filled_qty) is added with the sign of its side.

That gives the worst-case post-fill size assuming every working order on the symbol fills. Some consequences worth knowing:

  • A long 400 of AAPL can be sold by 900 (projected abs(400 - 900) = 500, OK) — net-flat-and-then-some is allowed, because the resulting short of 500 is still within the limit.
  • Net-flat operations are always allowed: selling 100 of a 100-long projects to 0.
  • In-flight orders count. Two concurrent BUY 100 orders on a symbol with cap 100 will not both pass: the first one occupies the cap, so the second projects to 200 and is rejected. Mirrors max_open_notional's sum-of-working semantics. Cancelling or filling an in-flight order releases its share of the cap.

If you want a direction limit ("never go short AAPL"), encode it in your own pre-submission check before calling strix.order. The SDK's risk surface is size-based.

max_open_notional (the spec's "buying power")

Cap on the sum of working-order notionals at any moment. Open orders consume the cap; filled, cancelled, and rejected orders free it. Filled positions don't count — max_open_notional is a notional cap, not a cash/margin tracker.

strix.init(risk=RiskSettings(max_open_notional=Decimal("100_000")))

with strix.order(symbol="AAPL", side=Side.BUY, qty=200, limit_price=Decimal("150")):
    broker.submit(...)   # open notional = 30_000

with strix.order(symbol="MSFT", side=Side.BUY, qty=200, limit_price=Decimal("400")):
    broker.submit(...)   # open notional = 30_000 + 80_000 = 110_000
# StrixRiskError: projected open notional 110_000 would exceed max_open_notional 100_000

Notional for an order is computed from:

  • Limit ordersqty × limit_price directly.
  • Market orders — needs a price. Strix queries the MarketDataAdapter: quote() (ask for BUY, bid for SELL), falling back to last(). If neither is available, the missing-data policy applies.
  • Partially filled market orders — uses avg_fill_price for the unfilled portion (the already-filled fills are reality; the remainder will fill at similar prices in expectation).

This is intentionally not "buying power" — Strix can't see your broker's cash, margin, options requirements, or settlement. If you need real BP, integrate your broker's BP API yourself.

Price thresholds

Three settings cap or floor the execution price for any order:

  • max_share_price — reject if the reference price would be above this. Use to keep your bot out of expensive names.
  • min_share_price — reject if below this. Use to filter pennies / illiquid stocks.
  • min_share_price_short — reject if below this AND the SELL would result in a short position (SSR-style floor for short sales).
strix.init(risk=RiskSettings(
    min_share_price=Decimal("5"),
    max_share_price=Decimal("500"),
    min_share_price_short=Decimal("10"),
))

The "reference price" is computed as:

  • If the order has limit_price: that value (the worst-case execution).
  • Else: quote.ask (BUY) / quote.bid (SELL), falling back to last().
  • If neither is available: missing-data policy.

min_share_price_short only fires when the projected post-fill position is short (long-to-short flip or opening short from flat). Selling within an existing long doesn't trigger it.

Stop-losses (continuous risk)

Unlike the checks above, stop-losses fire continuously — after every execution (realized-P&L change) and after every mark_to_market call (unrealized-P&L change). When triggered, they halt new exposure-adding orders until the configured recovery condition is met or strix.resume_trading() is called.

SessionStopLoss — session-wide

from strix import SessionStopLoss

strix.init(
    market_data=feed,
    risk=RiskSettings(
        session_stop_loss=SessionStopLoss(
            threshold=Decimal("-5000"),           # halt at -$5,000 P&L
            recovery_threshold=Decimal("-1000"),  # unhalt at -$1,000
            mode="total",                         # realized + unrealized
        ),
    ),
)

mode="total" (default) needs marks via mark_to_market — unrealized drawdown only fires when marks update. mode="realized" evaluates only on fills and needs no market data, but misses unrealized drawdown by design.

recovery_threshold must be > threshold if set. None disables auto-recovery; the only way out is manual override.

CostBasedStopLoss — per-position

from strix import CostBasedStopLoss

strix.init(
    market_data=feed,
    risk=RiskSettings(
        cost_based_stop_loss=CostBasedStopLoss(
            threshold_pct=Decimal("-0.10"),          # halt at -10% on cost basis
            recovery_threshold_pct=Decimal("-0.05"), # unhalt at -5%
        ),
    ),
)

Computes pnl_pct = (mark - avg_price) * qty / (avg_price * abs(qty)) for every non-zero position. Halts the symbol independently — other symbols keep trading. Closing the halted position auto-clears the halt (the dimension being measured no longer exists), even without an explicit recovery threshold.

threshold_pct must be negative. No mode field — per-position realized-only has no clean semantics; use SessionStopLoss(mode="realized") if that's what you want.

Feeding marks: strix.mark_to_market + strix.active_symbols

# In your strategy tick loop:
for sym in strix.active_symbols():
    if sym in your_feed:
        marks[sym] = your_feed.last_price(sym)
strix.mark_to_market(marks)

active_symbols() returns the set of symbols Strix needs marks for — non-zero positions OR open orders. Empty payloads are no-ops; non-empty payloads update the in-memory mark cache and trigger continuous-risk evaluation. Marks are not individually logged — they feed a throttled PnLSnapshot instead.

If stale_mark_threshold is set (default 5s, None disables), Strix logs a WARNING when:

  • mark_to_market is called and an active symbol is missing from the payload.
  • Stop-loss evaluation uses a cached mark older than the threshold.

Halt semantics: exposure-reducing orders pass through

When a stop is triggered, Strix doesn't block every order — that would prevent the trader from cutting losses. The rule: opposite direction AND order.qty ≤ |current position| is "exposure-reducing" and bypasses the halt.

Current Order Effect Halt behavior
long 100 SELL 50 reduce to long 50 allow
long 100 SELL 100 flat allow
long 100 SELL 150 flat → short 50 block (flip = new exposure)
long 100 BUY 50 add block
flat BUY/SELL open block

Other risk checks (max_qty_per_order etc.) still apply to reducing orders.

Manual override

strix.resume_trading(reason="market re-opened; intentional re-entry")

Clears every active halt in one shot. Emits StopLossManuallyOverridden carrying the cleared halts + your reason for the audit trail. The stop-loss config is unchanged — if P&L is still under threshold, the next execution or mark_to_market call will re-trigger. The override gives you a window to act; it doesn't disable the stop.

Catch StrixHaltedError (separate from StrixRiskError) to detect halts in your strategy:

from strix import StrixHaltedError

try:
    with strix.order(symbol="AAPL", side=Side.BUY, qty=100):
        broker.submit(...)
except StrixHaltedError as e:
    log.warning("halted: scope=%s subject=%s", e.scope, e.subject)

Missing market data

When a check needs a price the adapter can't supply, SessionConfig.on_missing_market_data decides:

  • "reject" (default): the check treats missing data as a breach. With on_breach="raise" this raises StrixRiskError (fail closed); with the default on_breach="warn" it's recorded as a RiskBreach and the order proceeds.
  • "allow": pre-trade checks pass with a WARNING log. For users with gappy feeds.

on_missing_market_data decides whether missing data counts as a breach; on_breach decides whether a breach gates. For a true fail-closed gate on missing data, use both on_missing_market_data="reject" and on_breach="raise".

This only governs pre-trade checks. Continuous-risk evaluation always skips missing-mark positions with a WARNING regardless — there's no order to reject mid-evaluation.

Users who want per-symbol fail-open behavior implement it inside the adapter: return a synthetic worst-case quote (e.g. last × 1.05) where you're comfortable trading without real data, return None only where you want hard rejection.

Changing risk mid-session

strix.set_risk(RiskSettings(...)) replaces the active session's policy in flight. The whole RiskSettings is replaced atomically — no partial update path. The new policy applies to the next order; orders already past the risk check are not re-evaluated.

strix.set_risk(RiskSettings(max_qty_per_order=100))   # tighten

# ...market changes, loosen up...
strix.set_risk(RiskSettings(max_qty_per_order=10_000))

# ...end of risky window, clear all limits...
strix.set_risk(RiskSettings())

Each call emits a RiskSettingsChanged event so the change is persisted and replayed by strix.resume.

Handling rejections

import strix
from strix import Side, StrixRiskError

try:
    with strix.order(symbol="AAPL", side=Side.BUY, qty=2000) as o:
        broker.submit(o.order_id, o.symbol, o.side, o.qty)
except StrixRiskError as e:
    log.warning("blocked: %s (order_id=%s)", e.reason, e.order_id)
    # Decide: skip, downsize, escalate to a human, …

StrixRiskError carries two fields:

  • reason — the human-readable reason (also the message of the exception).
  • order_id — the Strix order_id of the blocked order. The order is in the order book as REJECTED and is visible in strix.positions() via its symbol if relevant.

A risk rejection is not an error in the program's sense — it's the system doing its job. Catch it, log it, and move on. Don't let it propagate as if it were a bug.

This try/except only applies in on_breach="raise" mode. Under the default on_breach="warn", no exception is thrown — the breach surfaces as a RiskBreach event (and a WARNING log) while the order proceeds. Read those off the event stream / dashboard rather than catching an exception.

Combining checks

Pre-trade checks (strix.order entry) run in this order; first to fail wins (in on_breach="raise" the first failure throws and the rest are skipped; in on_breach="warn" the first breach is recorded as a RiskBreach and the order proceeds):

  1. Session halt (StrixHaltedError, with exposure-reducing bypass)
  2. Position halt (StrixHaltedError, with exposure-reducing bypass)
  3. max_qty_per_order
  4. min_qty_per_order
  5. max_orders
  6. max_open_orders
  7. max_shares_per_equity_symbol / position_limits (per-symbol override wins)
  8. max_open_notional
  9. Price thresholds (max_share_price, min_share_price, min_share_price_short)

Continuous-risk checks run after ExecutionApplied, after mark_to_market, and after RiskSettingsChanged:

  • SessionStopLoss evaluation (session-wide P&L)
  • CostBasedStopLoss evaluation (per-symbol drawdown vs cost basis)

The default on_breach="warn" is soft enforcement — every check records a RiskBreach and warns without blocking. Use on_breach="raise" to make a breach hard-block instead. The mode is session-wide (all checks share it); there's no per-check mode in v1.

PnL snapshots

Strix records P&L over time via the PnLSnapshot event rather than logging every mark. Each snapshot carries:

  • realized — cumulative session realized P&L.
  • unrealized — sum of per-symbol unrealized P&L across non-zero positions with a known mark.
  • by_symbol — per-symbol {qty, avg_price, mark, unrealized}.

Snapshots are emitted by mark_to_market and ingest_execution whenever:

  • Something has changed since the last snapshot (a new fill or new mark), and
  • At least SessionConfig.pnl_snapshot_every_seconds (default 15.0) seconds have elapsed since the previous snapshot.

The throttle is bypassed (force-flush) on stop-loss events (StopLossTriggered / StopLossRecovered) so the audit trail captures trigger context, and on SessionEnded so the last-known state is recorded. Idle sessions emit nothing — no heartbeat snapshots.

Configure the cadence to match your strategy's pace:

import strix
from strix import SessionConfig

strix.init(config=SessionConfig(pnl_snapshot_every_seconds=5.0))

For local-only users this field is fully user-controlled. For CloudTransport, the server overrides it at session start based on the active plan — local strategies still control all behavior; cloud-bound audit cadence is plan-driven.

Test and demo loops that run faster than real time can outrun the throttle: a tight batch that completes in a few hundred milliseconds with pnl_snapshot_every_seconds=15.0 will emit only one snapshot (plus the force-flush at session close). Lower the interval to match your simulated bar rate (pnl_snapshot_every_seconds=0.01 for batch tests) or insert a short sleep between bars. The throttle works on wall-clock time, not bar count.

The first call to mark_to_market or ingest_execution on a fresh session is silently skipped if it would produce a zero/empty snapshot (no positions, no realized P&L) — there's nothing to chart yet. Once any state has moved, subsequent snapshots flow normally, including the close-to-flat case.

What's not in v1

  • Per-order-side limits. position_limits is direction-agnostic; you can't say "max long 500, max short 200". Encode externally if you need it.
  • Real cash / margin tracking. max_open_notional is a notional cap on working orders, not your broker's BP. Integrate your broker's BP API on your side if you need actual cash accounting.
  • Per-strategy or per-account scoping. Risk is global to the session. Multi-strategy attribution lives at the application layer.

Why pre-trade?

Checks run before the strix.order(...) block body — the place where you call your broker. By construction:

  1. Strix builds the Order.
  2. Strix runs risk checks against the projected state.
  3. The block body runs — and only the body knows how to submit to the broker.

In on_breach="raise" mode, a failed check throws at step 2, so the block body never runs and the broker is never called — Strix stops you from making the call, by raising before the body runs. In the default on_breach="warn" mode, a breach is recorded (RiskBreach) and step 3 still runs: the value is the recorded breach + replay, not prevention.

Either way Strix does not sit in the order path — it never touches your broker. If you bypass the strix.order block and call your broker directly, no risk checks fire at all.