Skip to main content

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

FieldWhat it meansTypical value
nav_usdFund's current NAV in USD$50k - $5M
max_gross_exposure_pctsum(|positions|) / NAV ceiling1.0 (100% gross) or 2.0 (2x leverage)
max_net_exposure_pctsum(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

GateCodeReason
idempotencyDUPLICATE_KEYSame idempotency_key seen recently
schemaINVALID_FIELDPydantic validation failed
staticBROKER_NOT_ALLOWEDBroker not in allowed_brokers
staticORDER_TYPE_NOT_ALLOWEDOrderType not in allowed_order_types
staticASSET_CLASS_NOT_ALLOWEDAsset class not in allowed_asset_classes
staticORDER_NOTIONAL_EXCEEDEDnotional > max_order_notional_usd
position_riskMAX_POSITION_EXCEEDEDResulting pct > max_position_pct
position_riskGROSS_EXPOSURE_EXCEEDEDResulting gross > max_gross_exposure_pct
position_riskNET_EXPOSURE_EXCEEDEDResulting net > max_net_exposure_pct
position_riskSECTOR_CONCENTRATIONSector pct > max_sector_pct
drawdown_haltDAILY_LOSS_HALTToday PL < -daily_loss_halt_pct
drawdown_haltWEEKLY_LOSS_HALTWeek PL < -weekly_loss_halt_pct
drawdown_haltMONTHLY_LOSS_HALTMonth PL < -monthly_loss_halt_pct
shortSHORTING_DISABLEDenable_shorting=False
shortBORROW_FEE_TOO_HIGHBorrow fee > max_short_fee_bps
cro_vetoCRO_VETO_FIREDLLM 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

SeverityScopeTriggered byReset
STRATEGY_HALTone strategy in one funddrawdown halt, drift_zscore < -2.0, manualoperator mts cmd resume <strategy_id>
FUND_HALTone fund (all strategies)fund daily_loss_halt_pct breachedoperator mts cmd resume <fund_id>
FIRM_HALTeverythingmanual or news_spike on aggregateoperator 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