Skip to main content

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
TypeSlippage riskFill certaintyUse when
MARKETHighVery highLiquid name, urgent
LIMITNone at priceLow if priced throughDefault for size; reduces impact
STOPHigh (gap risk)Triggers + fills like MARKETStop-loss; pair with extended_hours=True if needed
STOP_LIMITNone at priceMay not fill if gap-throughStop-loss without slippage; risk = no fill
MARKETABLE_LIMITMinimalHigh"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

FieldRequiredValidation
order_idnon-empty string
idempotency_keymin_length=1
symbolnon-empty Symbol
sideSide enum
quantitygt=0
order_typeOrderType 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
tifdefault DAYTimeInForce enum
fund_id
strategy_id
broker
actor
extended_hoursdefault False
nav_usdoptionalgt=0 if set
thesisoptional
created_atdatetime
*_at, broker_order_id, rejected_reasonn/aset 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