mts1b_foundation.risk — full reference
The risk envelope schema + structured rejection + halt-request types. Implementations of the gates that USE these live in mts1b-riskengine.
Quick example
from datetime import datetime, timezone
from decimal import Decimal
from mts1b_foundation.risk import RiskEnvelope
env = RiskEnvelope(
envelope_id="env-001",
fund_id="paper-momentum",
nav_usd=Decimal("100000"),
max_position_pct=0.10,
daily_loss_halt_pct=0.03,
allowed_brokers=["paper", "ibkr"],
enable_shorting=False,
created_at=datetime.now(timezone.utc),
)
print(env.model_dump_json(indent=2))
class RiskEnvelope
Per-fund risk envelope. Versioned + audit-trailed.
Full schema
class RiskEnvelope(BaseModel):
envelope_id: str
fund_id: str
# Capital limits
nav_usd: Decimal # gt=0
max_gross_exposure_pct: float = 1.0 # 0..5
max_net_exposure_pct: float = 1.0 # -5..5
# Per-position
max_position_pct: float = 0.05 # 0..1
max_position_usd: Decimal | None = None
# Concentration
max_sector_pct: float = 0.30 # 0..1
max_asset_class_pct: float = 1.0 # 0..1
max_country_pct: float = 1.0 # 0..1
# Drawdown halt
daily_loss_halt_pct: float = 0.03 # 0..1
weekly_loss_halt_pct: float = 0.08 # 0..1
monthly_loss_halt_pct: float = 0.15 # 0..1
# Order-level
max_order_notional_usd: Decimal = Decimal("100000")
max_order_qty: Decimal | None = None
allowed_brokers: list[str] = []
allowed_order_types: list[str] = ["market", "limit"]
allowed_asset_classes: list[str] = ["equities"]
# Short side
enable_shorting: bool = False
max_short_fee_bps: float = 200.0
require_borrow_locate: bool = False
# Synthetic stops
enable_synthetic_stop: bool = True
synthetic_stop_pct: float = 0.10
# CRO veto (optional LLM-based)
cro_veto_enabled: bool = False
cro_veto_confidence_threshold: float = 0.85
cro_veto_timeout_s: float = 5.0
# Versioning
version: int = 1
created_at: datetime
notes: str = ""
Field walkthrough
Capital limits
| Field | What it means | Typical value |
|---|---|---|
nav_usd | Fund's current NAV in USD | $50k - $5M |
max_gross_exposure_pct | sum(|positions|) / NAV ceiling | 1.0 (100% gross) or 2.0 (2x leverage) |
max_net_exposure_pct | sum(positions) / NAV ceiling (signed) | 1.0 (long-only) or 0.0 (market-neutral) |
# Long-only equities, 1x leverage cap
env_long_only = RiskEnvelope(
envelope_id="env-1", fund_id="f1", nav_usd=Decimal("100000"),
max_gross_exposure_pct=1.0, max_net_exposure_pct=1.0,
created_at=datetime.now(timezone.utc),
)
# L/S equity, 1.5x gross, market-neutral net
env_ls = RiskEnvelope(
envelope_id="env-2", fund_id="f2", nav_usd=Decimal("500000"),
max_gross_exposure_pct=1.5, max_net_exposure_pct=0.2, # max ±20% net
enable_shorting=True,
created_at=datetime.now(timezone.utc),
)
Per-position limits
# Cap any single position at 5% NAV
env.max_position_pct = 0.05
# Or absolute USD cap regardless of NAV
env.max_position_usd = Decimal("25000")
When BOTH are set, the stricter wins:
nav = Decimal("100000")
pct_cap = Decimal(str(env.max_position_pct)) * nav # $5,000
abs_cap = env.max_position_usd # $25,000
effective_cap = min(pct_cap, abs_cap) # $5,000
Concentration
# No more than 30% in any single sector
env.max_sector_pct = 0.30
# No more than 70% in any single asset class (equities, crypto, ...)
env.max_asset_class_pct = 0.70
# No more than 50% in any single country (for international portfolios)
env.max_country_pct = 0.50
Drawdown halt thresholds
env.daily_loss_halt_pct = 0.03 # halt if today's PL < -3%
env.weekly_loss_halt_pct = 0.08 # halt if this week < -8%
env.monthly_loss_halt_pct = 0.15 # halt if this month < -15%
When any threshold breaches, mts1b-riskengine publishes mts.v1.risk.halt.requested. mts1b-oms stops accepting new risk-on orders.
Sticky: requires mts cmd resume <fund_id> to lift.
Order-level limits
env.max_order_notional_usd = Decimal("50000") # max $50k per order
env.allowed_brokers = ["paper", "ibkr"] # whitelist
env.allowed_order_types = ["limit", "marketable_limit"] # no MARKET
env.allowed_asset_classes = ["equities", "options"] # no crypto
Short side
env.enable_shorting = True # required to allow Side.SHORT
env.max_short_fee_bps = 200.0 # don't short if borrow fee > 200bps
env.require_borrow_locate = True # v2: hard-to-borrow check
Synthetic stops
Off by default for paper funds; ON for live:
env.enable_synthetic_stop = True
env.synthetic_stop_pct = 0.10 # close if down 10% from entry
# A worker in mts1b-riskengine polls quotes; if price crosses, it emits
# a closing order via OMS — same risk gates apply.
CRO veto (LLM-based, advisory)
env.cro_veto_enabled = True
env.cro_veto_confidence_threshold = 0.85 # only veto if LLM confidence ≥ 0.85
env.cro_veto_timeout_s = 5.0 # fail-OPEN at 5s
# Edge-case override for orders that pass all deterministic gates.
# Routed to mts1b-llm persona "CRO".
class OrderRejection
What gets published when an order fails a risk gate.
class OrderRejection(BaseModel):
order_id: str
envelope_id: str
gate: str # "static" | "position_risk" | "drawdown_halt" | "short" | "cro_veto"
code: str # uppercase: "MAX_POSITION_EXCEEDED", "DAILY_LOSS_HALT", ...
reason: str # human-readable
details: dict # gate-specific context
timestamp: datetime
Example
from mts1b_foundation.risk import OrderRejection
rej = OrderRejection(
order_id="ord-001",
envelope_id="env-001",
gate="position_risk",
code="MAX_POSITION_EXCEEDED",
reason="Resulting AAPL position would be 12% of NAV; cap is 10%",
details={
"symbol": "AAPL",
"would_be_pct": 0.12,
"cap_pct": 0.10,
"current_qty": "100",
"incoming_qty": "150",
},
timestamp=datetime.now(timezone.utc),
)
Standard rejection codes
| Gate | Code | Reason |
|---|---|---|
idempotency | DUPLICATE_KEY | Same idempotency_key seen recently |
schema | INVALID_FIELD | Pydantic validation failed |
static | BROKER_NOT_ALLOWED | Broker not in allowed_brokers |
static | ORDER_TYPE_NOT_ALLOWED | OrderType not in allowed_order_types |
static | ASSET_CLASS_NOT_ALLOWED | Asset class not in allowed_asset_classes |
static | ORDER_NOTIONAL_EXCEEDED | notional > max_order_notional_usd |
position_risk | MAX_POSITION_EXCEEDED | Resulting pct > max_position_pct |
position_risk | GROSS_EXPOSURE_EXCEEDED | Resulting gross > max_gross_exposure_pct |
position_risk | NET_EXPOSURE_EXCEEDED | Resulting net > max_net_exposure_pct |
position_risk | SECTOR_CONCENTRATION | Sector pct > max_sector_pct |
drawdown_halt | DAILY_LOSS_HALT | Today PL < -daily_loss_halt_pct |
drawdown_halt | WEEKLY_LOSS_HALT | Week PL < -weekly_loss_halt_pct |
drawdown_halt | MONTHLY_LOSS_HALT | Month PL < -monthly_loss_halt_pct |
short | SHORTING_DISABLED | enable_shorting=False |
short | BORROW_FEE_TOO_HIGH | Borrow fee > max_short_fee_bps |
cro_veto | CRO_VETO_FIRED | LLM persona vetoed with confidence > threshold |
class HaltRequest
What gets published to mts.v1.operations.halt.requested to trigger a halt.
class HaltRequest(BaseModel):
request_id: str
severity: str # "STRATEGY_HALT" | "FUND_HALT" | "FIRM_HALT"
fund_id: str | None
strategy_id: str | None
reason: str
requested_by: str
requires_cosign: bool = True
timestamp: datetime
Severity levels
| Severity | Scope | Triggered by | Reset |
|---|---|---|---|
STRATEGY_HALT | one strategy in one fund | drawdown halt, drift_zscore < -2.0, manual | operator mts cmd resume <strategy_id> |
FUND_HALT | one fund (all strategies) | fund daily_loss_halt_pct breached | operator mts cmd resume <fund_id> |
FIRM_HALT | everything | manual or news_spike on aggregate | operator co-sign (2FA + signed CLI) |
Examples
# Daily drawdown auto-halt
halt = HaltRequest(
request_id="halt-2026-05-23-001",
severity="FUND_HALT",
fund_id="paper-momentum",
reason="Daily PL -3.2% breached -3.0% threshold",
requested_by="mts1b-riskengine",
requires_cosign=False, # auto-halts don't need co-sign
timestamp=datetime.now(timezone.utc),
)
# Manual firm halt
halt = HaltRequest(
request_id="halt-2026-05-23-002",
severity="FIRM_HALT",
fund_id=None,
reason="News spike on Fed policy; risk-off until ops review",
requested_by="operator:mondipsen",
requires_cosign=True,
timestamp=datetime.now(timezone.utc),
)
Common envelope patterns
Paper-trading sandbox (permissive)
sandbox = RiskEnvelope(
envelope_id="env-sandbox",
fund_id="paper-sandbox",
nav_usd=Decimal("100000"),
max_gross_exposure_pct=2.0,
max_position_pct=0.50, # let experiments breathe
daily_loss_halt_pct=0.10, # halts at -10% (loose)
allowed_brokers=["paper"],
enable_shorting=True,
enable_synthetic_stop=False, # paper; doesn't matter
created_at=datetime.now(timezone.utc),
)
Live equities long-only (strict)
prod_equities = RiskEnvelope(
envelope_id="env-prod-equities",
fund_id="live-equities-ls",
nav_usd=Decimal("250000"),
max_gross_exposure_pct=1.0, # no leverage
max_position_pct=0.05, # max 5% any name
max_order_notional_usd=Decimal("12500"), # 5% of $250k
daily_loss_halt_pct=0.02, # tight: -2%
allowed_brokers=["ibkr"],
allowed_order_types=["limit", "marketable_limit"], # no MARKET
allowed_asset_classes=["equities"],
enable_shorting=False,
enable_synthetic_stop=True,
synthetic_stop_pct=0.07,
cro_veto_enabled=True,
created_at=datetime.now(timezone.utc),
)
Live crypto market-neutral L/S (medium-strict)
prod_crypto = RiskEnvelope(
envelope_id="env-prod-crypto",
fund_id="live-crypto-revol",
nav_usd=Decimal("50000"),
max_gross_exposure_pct=1.0,
max_net_exposure_pct=0.20,
max_position_pct=0.25, # crypto = fewer names = larger caps
max_order_notional_usd=Decimal("6250"), # ~12% of NAV
daily_loss_halt_pct=0.05, # -5% (crypto is volatile)
weekly_loss_halt_pct=0.12,
allowed_brokers=["coinbase"],
allowed_order_types=["limit"],
allowed_asset_classes=["crypto"],
enable_shorting=True,
enable_synthetic_stop=True,
synthetic_stop_pct=0.08,
created_at=datetime.now(timezone.utc),
)
Updating an envelope (versioning)
async def tighten_paper_to_2pct_halt(fund_id: str):
current = await fetch_envelope(fund_id)
new_env = current.model_copy(update={
"envelope_id": f"env-{datetime.now(timezone.utc).isoformat()}", # new id
"daily_loss_halt_pct": 0.02,
"version": current.version + 1,
"created_at": datetime.now(timezone.utc),
"notes": f"Tightened daily halt from {current.daily_loss_halt_pct} to 0.02",
})
await publish_envelope(new_env)
The previous envelope stays in the audit chain forever. Lookups by envelope_id resolve to a specific version.
Common mistakes
Don't loosen during a drawdown
# Fund is currently halted at -3% daily PL
await tighten_paper_to_5pct_halt(fund_id) # BLOCKED by ops
# The riskengine enforces: while halted, envelope changes can ONLY tighten.
# To loosen, the operator must first manually `mts cmd resume <fund_id>`.
Don't share envelopes across funds
# WRONG: same envelope_id reused
env1 = RiskEnvelope(envelope_id="env-1", fund_id="fund-A", ...)
env2 = RiskEnvelope(envelope_id="env-1", fund_id="fund-B", ...) # same id!
# RIGHT: per-fund unique envelope_ids
env1 = RiskEnvelope(envelope_id="env-fund-A-001", fund_id="fund-A", ...)
env2 = RiskEnvelope(envelope_id="env-fund-B-001", fund_id="fund-B", ...)
See also
- Concept: Risk envelopes — the full 7-gate hierarchy
mts1b-riskengine— implementations of the gatesorders— input to the gatesfunds—FundConfigreferences the envelope