mts1b_foundation.orders — full reference
Every type the trading pipeline uses. Pydantic v2, frozen-by-default, JSON round-trippable.
Quick example
from datetime import datetime, timezone
from decimal import Decimal
from mts1b_foundation.orders import Order, Side, OrderType, TimeInForce
from mts1b_foundation.symbology import Symbol
order = Order(
order_id="ord-001",
idempotency_key="strategy-v7-2026-05-23T16:00-AAPL-long",
symbol=Symbol("AAPL"),
side=Side.BUY,
quantity=Decimal("100"),
order_type=OrderType.LIMIT,
limit_price=Decimal("180.50"),
tif=TimeInForce.DAY,
fund_id="paper-momentum",
strategy_id="momentum_v1",
broker="paper",
actor="research_signal_executor",
created_at=datetime.now(timezone.utc),
)
print(order.model_dump_json(indent=2))
Side
class Side(str, Enum):
BUY = "buy" # Open or add to a long position
SELL = "sell" # Close or reduce a long position
SHORT = "short" # Open or add to a short position
COVER = "cover" # Close or reduce a short position
The distinction between SELL and COVER matters for tax-lot accounting and short-borrow tracking. The OMS uses these to route reconciliation events.
Example: programmatic side selection
def open_or_close(side_intent: str, current_qty: Decimal) -> Side:
"""Map a "buy" or "sell" intent to BUY/SELL/SHORT/COVER based on current position."""
if side_intent == "buy":
return Side.BUY if current_qty >= 0 else Side.COVER
elif side_intent == "sell":
return Side.SELL if current_qty > 0 else Side.SHORT
raise ValueError(f"unknown side_intent={side_intent}")
open_or_close("buy", Decimal("0")) # Side.BUY (open long)
open_or_close("buy", Decimal("-100")) # Side.COVER (close short)
open_or_close("sell", Decimal("100")) # Side.SELL (close long)
open_or_close("sell", Decimal("0")) # Side.SHORT (open short)
OrderType
class OrderType(str, Enum):
MARKET = "market" # Immediate at NBBO
LIMIT = "limit" # Only at limit_price or better
STOP = "stop" # Triggers MARKET at stop_price
STOP_LIMIT = "stop_limit" # Triggers LIMIT at stop_price + limit_price
MARKETABLE_LIMIT = "marketable_limit" # LIMIT priced at touch; behaves like MARKET
| Type | Slippage risk | Fill certainty | Use when |
|---|---|---|---|
MARKET | High | Very high | Liquid name, urgent |
LIMIT | None at price | Low if priced through | Default for size; reduces impact |
STOP | High (gap risk) | Triggers + fills like MARKET | Stop-loss; pair with extended_hours=True if needed |
STOP_LIMIT | None at price | May not fill if gap-through | Stop-loss without slippage; risk = no fill |
MARKETABLE_LIMIT | Minimal | High | "Take-the-other-side"; safer than MARKET outside RTH |
Example: pick order type based on session + size
def pick_order_type(notional_usd: Decimal, in_rth: bool) -> OrderType:
if not in_rth:
return OrderType.LIMIT # Many brokers reject MARKET outside RTH
if notional_usd < Decimal("5000"):
return OrderType.MARKETABLE_LIMIT
return OrderType.LIMIT # Larger sizes: use mts1b-oms-algos for VWAP/TWAP
TimeInForce
class TimeInForce(str, Enum):
DAY = "day" # Expires at session close
GTC = "gtc" # Good-till-canceled (broker-side max ~30/60/180 days)
IOC = "ioc" # Immediate-or-cancel: fill what you can now, kill the rest
FOK = "fok" # Fill-or-kill: all-or-nothing
OPG = "opg" # On-the-open: queue for the next opening cross
IOC + FOK semantic
# IOC: take what's available now, cancel any unfilled remainder
fast_take = Order(..., tif=TimeInForce.IOC, order_type=OrderType.LIMIT,
quantity=Decimal("10000"), limit_price=Decimal("180.50"))
# If only 6500 share available at $180.50, fill 6500; cancel 3500. No "working" remainder.
# FOK: fill ALL or NOTHING
all_or_nothing = Order(..., tif=TimeInForce.FOK, order_type=OrderType.LIMIT,
quantity=Decimal("10000"), limit_price=Decimal("180.50"))
# If only 9999 available at $180.50, REJECT. No fill.
Order — the full schema
class Order(BaseModel):
model_config = ConfigDict(frozen=True, str_strip_whitespace=True)
# Identity
order_id: str # UUID assigned by mts1b-oms
idempotency_key: str # Caller-provided dedupe key
# What
symbol: Symbol
side: Side
quantity: Decimal # gt=0
order_type: OrderType
limit_price: Decimal | None = None # required if LIMIT or STOP_LIMIT
stop_price: Decimal | None = None # required if STOP or STOP_LIMIT
tif: TimeInForce = TimeInForce.DAY
# Routing
fund_id: str
strategy_id: str
broker: str # "paper", "ibkr", "coinbase", ...
actor: str # Who created this (audit trail)
# Optional
extended_hours: bool = False
nav_usd: Decimal | None = None
thesis: str | None = None
# State (mutable in mts1b-oms)
created_at: datetime
submitted_at: datetime | None = None
accepted_at: datetime | None = None
canceled_at: datetime | None = None
rejected_reason: str | None = None
broker_order_id: str | None = None
Required + optional
| Field | Required | Validation |
|---|---|---|
order_id | ✅ | non-empty string |
idempotency_key | ✅ | min_length=1 |
symbol | ✅ | non-empty Symbol |
side | ✅ | Side enum |
quantity | ✅ | gt=0 |
order_type | ✅ | OrderType enum |
limit_price | ⚠️ | required iff order_type is LIMIT, STOP_LIMIT, or MARKETABLE_LIMIT |
stop_price | ⚠️ | required iff order_type is STOP or STOP_LIMIT |
tif | default DAY | TimeInForce enum |
fund_id | ✅ | |
strategy_id | ✅ | |
broker | ✅ | |
actor | ✅ | |
extended_hours | default False | |
nav_usd | optional | gt=0 if set |
thesis | optional | |
created_at | ✅ | datetime |
*_at, broker_order_id, rejected_reason | n/a | set by OMS during lifecycle |
Mutability — model_copy is your friend
Order is frozen — attribute assignment raises. To "update" an Order, use model_copy:
order = Order(..., created_at=datetime.now(timezone.utc))
# OMS marks it submitted
submitted = order.model_copy(update={"submitted_at": datetime.now(timezone.utc)})
# Then accepted
accepted = submitted.model_copy(update={"accepted_at": datetime.now(timezone.utc),
"broker_order_id": "BROKER-123"})
# All three are distinct objects; equality is by content
assert order != submitted
assert submitted != accepted
Idempotency — how to write a good key
Bad: idempotency_key = str(uuid.uuid4()) — every retry creates a new "order"; OMS will dedupe nothing.
Good: derive from inputs the strategy knows:
def momentum_v1_idempotency_key(strategy_id: str, rebalance_ts: datetime,
symbol: Symbol, side: Side) -> str:
"""Same (strategy, rebalance, symbol, side) = same key. Retries are safe."""
return f"{strategy_id}-{rebalance_ts.isoformat()}-{symbol}-{side.value}"
OMS dedupes within a 5-minute window. If you have a multi-leg strategy and need >5min idempotency, append a hash:
import hashlib
def long_window_idempotency_key(*args) -> str:
payload = "-".join(str(a) for a in args)
return hashlib.sha256(payload.encode()).hexdigest()[:32]
Extended-hours orders
# IBKR / NASDAQ allows extended-hours trading 4-9:30 ET (pre), 16-20 ET (post),
# 20-3:50 ET (IBEOS for select symbols).
pre_market = Order(
...,
order_type=OrderType.LIMIT, # MARKET is rejected outside RTH
limit_price=Decimal("180.50"),
tif=TimeInForce.DAY, # Will only live in the pre-market session
extended_hours=True,
...
)
gtc_pre = Order(
...,
order_type=OrderType.LIMIT,
tif=TimeInForce.GTC, # Survives session boundaries
extended_hours=True, # Eligible during ext hours too
...
)
JSON serialization round-trip
order = Order(...)
# Serialize
payload = order.model_dump_json() # bytes-encodable JSON string
payload_dict = order.model_dump() # Python dict
# Validate
roundtrip = Order.model_validate_json(payload)
roundtrip_from_dict = Order.model_validate(payload_dict)
assert order == roundtrip == roundtrip_from_dict
The dump preserves Decimal as strings (no precision loss). datetime becomes ISO-8601 strings.
Pydantic schema export
schema = Order.model_json_schema()
# Standard JSON-Schema; feed into FastAPI, openapi-typescript, or any validator
For OpenAPI, use mts1b_foundation.openapi.to_openapi(Order, ...).
Fill
class Fill(BaseModel):
model_config = ConfigDict(frozen=True)
fill_id: str
order_id: str
symbol: Symbol
side: Side
quantity: Decimal # gt=0
price: Decimal # gt=0
fees: Decimal = Decimal("0")
venue: str
timestamp: datetime
raw: dict = {}
Fill is what comes back from a broker. The raw field carries the broker-native payload for audit trail.
Computing average price from multiple partial fills
def avg_price(fills: list[Fill]) -> Decimal:
"""Volume-weighted average price across partial fills."""
notional = sum((f.quantity * f.price for f in fills), Decimal("0"))
total_qty = sum((f.quantity for f in fills), Decimal("0"))
return notional / total_qty if total_qty > 0 else Decimal("0")
fills = [
Fill(..., quantity=Decimal("40"), price=Decimal("180.45")),
Fill(..., quantity=Decimal("40"), price=Decimal("180.50")),
Fill(..., quantity=Decimal("20"), price=Decimal("180.55")),
]
print(avg_price(fills)) # Decimal('180.4900')
Fees tracking
def gross_proceeds_usd(fill: Fill) -> Decimal:
"""Notional value of the fill, ignoring fees."""
return fill.quantity * fill.price
def net_proceeds_usd(fill: Fill, side: Side) -> Decimal:
"""Net cash impact. BUY/COVER spends cash, SELL/SHORT receives cash."""
gross = gross_proceeds_usd(fill)
if side in (Side.BUY, Side.COVER):
return -(gross + fill.fees)
return gross - fill.fees
Common validation errors
# Negative quantity
try:
Order(..., quantity=Decimal("-1"))
except Exception as e:
print(e)
# 1 validation error for Order
# quantity
# Input should be greater than 0
# Missing required field
try:
Order(quantity=Decimal("1"))
except Exception as e:
print(e)
# 9 validation errors for Order
# order_id Field required ...
# idempotency_key Field required ...
# ...
# Empty idempotency key
try:
Order(..., idempotency_key="")
except Exception as e:
print(e)
# 1 validation error for Order
# idempotency_key
# String should have at least 1 character
Anti-patterns
Don't reach into Order state from a strategy
❌ Wrong:
# in mts1b-research:
order = Order(...)
order.accepted_at = datetime.utcnow() # AttributeError: model is frozen
✅ Right:
# in mts1b-oms only — strategies just observe state via NATS events
from mts1b_foundation.orders import Order
async def on_accepted(order_id: str):
order = await store.get(order_id)
updated = order.model_copy(update={"accepted_at": datetime.now(timezone.utc)})
await store.put(updated)
await js.publish("mts.v1.oms.orders.accepted",
updated.model_dump_json().encode())
Don't share idempotency keys across funds
❌ Wrong: f"momentum-2026-05-23-AAPL" — shared across paper + live funds
✅ Right: f"momentum-{fund_id}-2026-05-23-AAPL" — distinct per fund
Don't put PII in thesis
The thesis field is logged + audit-trailed. Don't write "buying for Joe Smith ssn=123-45-6789". Use actor for accountability, not thesis.
See also
positions— what happens after fills accumulaterisk— what gates the order goes through pre-submit- Tutorial: Paper trading — full lifecycle in action