Skip to main content

Risk envelopes

A risk envelope is the set of constraints mts1b-riskengine enforces on every order before it reaches a broker. This page covers the envelope schema, the gate hierarchy, and what happens when a check fails.

The envelope schema

mts1b_foundation.risk
from decimal import Decimal
from pydantic import BaseModel, Field


class RiskEnvelope(BaseModel):
"""Per-fund risk envelope. Versioned + audit-trailed."""

envelope_id: str
fund_id: str

# Capital limits
nav_usd: Decimal = Field(..., gt=0)
max_gross_exposure_pct: float = Field(1.0, ge=0, le=5.0)
max_net_exposure_pct: float = Field(1.0, ge=-5.0, le=5.0)

# Per-position
max_position_pct: float = Field(0.05, gt=0, le=1.0)
max_position_usd: Decimal | None = None

# Concentration
max_sector_pct: float = Field(0.30, gt=0, le=1.0)
max_asset_class_pct: float = Field(1.0, gt=0, le=1.0)
max_country_pct: float = Field(1.0, gt=0, le=1.0)

# Drawdown halt
daily_loss_halt_pct: float = Field(0.03, gt=0, le=1.0)
weekly_loss_halt_pct: float = Field(0.08, gt=0, le=1.0)
monthly_loss_halt_pct: float = Field(0.15, gt=0, le=1.0)

# Order-level
max_order_notional_usd: Decimal = Field(Decimal("100000"))
max_order_qty: Decimal | None = None
allowed_brokers: list[str] = []
allowed_order_types: list[str] = ["market", "limit"]

# Short side
enable_shorting: bool = False
max_short_fee_bps: float = 200.0 # borrow cost ceiling
require_borrow_locate: bool = False # v2: hard-to-borrow check

# Synthetic stops
enable_synthetic_stop: bool = True
synthetic_stop_pct: float = 0.10

The gate hierarchy

Every order passes through gates in this order. The first failure aborts; the order is rejected with a structured reason.

┌──────────────────────────────────────────────┐
│ Order arrives at mts1b-oms │
└────────────────────┬─────────────────────────┘

┌───────────────────────────────────────────────────┐
│ Gate 1: Idempotency │
│ - dedupe on Order.idempotency_key │
│ - reject if duplicate within window │
└────────────────────────┬──────────────────────────┘

┌───────────────────────────────────────────────────┐
│ Gate 2: Schema validation (foundation) │
│ - pydantic.model_validate │
│ - reject if any required field missing/invalid │
└────────────────────────┬──────────────────────────┘

┌───────────────────────────────────────────────────┐
│ Gate 3: Static risk (riskengine.gates.static) │
│ - allowed_brokers, allowed_order_types │
│ - max_order_notional, max_order_qty │
│ - extended_hours flag matches venue capabilities │
└────────────────────────┬──────────────────────────┘

┌───────────────────────────────────────────────────┐
│ Gate 4: Position risk (riskengine.gates.position)│
│ - resulting position vs max_position_pct/usd │
│ - resulting gross/net exposure │
│ - sector / asset-class / country concentration │
└────────────────────────┬──────────────────────────┘

┌───────────────────────────────────────────────────┐
│ Gate 5: Drawdown halt (riskengine.gates.dd) │
│ - if today's P/L < -daily_loss_halt_pct → HALT │
│ - same for weekly/monthly │
│ - blocks ALL new risk-on orders until reset │
└────────────────────────┬──────────────────────────┘

┌───────────────────────────────────────────────────┐
│ Gate 6: Short-side checks │
│ - enable_shorting flag │
│ - borrow fee from brokers <= max_short_fee_bps │
│ - require_borrow_locate (v2) │
└────────────────────────┬──────────────────────────┘

┌───────────────────────────────────────────────────┐
│ Gate 7: CRO / persona veto (optional) │
│ - LLM-based override on edge cases │
│ - fail-OPEN with 5s timeout │
└────────────────────────┬──────────────────────────┘

order → broker

Each gate emits mts.v1.risk.gate.passed or mts.v1.risk.gate.failed to NATS, with the full envelope + order + reason for observability.

Rejection record

When a gate fails:

class OrderRejection(BaseModel):
order_id: str
envelope_id: str
gate: str # "position_risk", "drawdown_halt", ...
code: str # "MAX_POSITION_EXCEEDED", "DAILY_LOSS_HALT", ...
reason: str # human-readable
details: dict # gate-specific context
timestamp: datetime

Published to mts.v1.risk.gate.failed. mts1b-operations subscribes and:

  1. Logs to audit chain (immutable Merkle-hashed log).
  2. Sends Telegram alert via mts1b-platform/messaging (severity-based).
  3. If gate is drawdown_halt, fires a HaltRequest that prevents further orders.

Synthetic stops

mts1b-riskengine doesn't rely on broker-side stop-loss orders (they can be jumped on gaps). Instead it tracks synthetic stops:

  • Each filled position registers a synthetic stop at fill_price × (1 ± synthetic_stop_pct).
  • A background worker (riskengine/workers/synthetic_stop_runner.py) polls quotes every N seconds.
  • If price crosses the stop, the worker emits a closing order via the OMS — same path, same gates.

This means stops are honored even if the venue has a flash crash or partial outage.

Drawdown halt — the kill switch

daily_loss_halt_pct is the most important constraint. When P/L breaches:

  1. mts1b-riskengine publishes mts.v1.risk.halt.requested with severity DAILY_LOSS.
  2. mts1b-oms immediately stops accepting new risk-on orders for the fund.
  3. Existing positions are NOT auto-closed (you don't sell into a crash).
  4. Operator must explicitly mts cmd resume to lift the halt.

This bounded loss is the most important property of the system. Choose daily_loss_halt_pct carefully.

Envelope updates

Envelopes are versioned. Changing the envelope:

  1. Publish a new RiskEnvelope with the same envelope_id, incremented version.
  2. mts1b-riskengine reloads via mts.v1.risk.envelope.updated.
  3. The previous envelope is preserved in the audit chain.

You can NOT change an envelope to be more permissive while drawn down (the daily halt is "sticky" until manual reset).

Multi-fund

Each fund has its own envelope. mts1b-treasury may also have a portfolio-level envelope that limits cross-fund exposure (e.g., total firm gross < 5x NAV). Treasury's envelope is checked AFTER the per-fund envelopes pass.

See also