Common pitfalls
Real footguns. Each one was extracted from MTS1B's source monorepo where someone (often the maintainer) hit it the hard way.
Symbol & instrument
1. Coinbase quote inversion
The bug: Coinbase v2/exchange-rates returns rates[QUOTE] as QUOTE-per-BASE, not BASE-per-QUOTE. For BTC-USD, rates["USD"] = "100000" means 100,000 USD per 1 BTC. So the spot price IS that rate (NOT 1/rate).
The fix in MTS1B: 2026-05-05, the adapter was patched. Bug history preserved in mts1b-brokers/coinbase/client.py comments.
Lesson: Always sanity-check spot quotes against a known reference (CoinGecko, Bloomberg) when adding a new venue. A unit test that asserts BTC > $10k and < $10M catches obvious inversions.
2. BRK.B parsed as crypto pair
If you let symbol normalization run on BRK-B, it gets parsed as crypto (BRK / B). The canonical form is BRK.B with a dot.
Fix: Use Symbol("BRK.B") directly. If you have feeds emitting BRK-B, transform first:
def fix_class_share(s: str) -> str:
if s in ("BRK-B", "BRK-A"):
return s.replace("-", ".")
return s
3. IBKR PAXOS crypto uses bare symbols
IBKR's PAXOS-backed crypto venue uses bare 3-char symbols (BTC, ETH, LTC), NOT BTC-USD. The mts1b_brokers.ibkr adapter handles this:
to_native(Symbol("BTC-USD"), venue="ibkr_paxos") # "BTC"
to_native(Symbol("BTC-USD"), venue="coinbase") # "BTC-USD"
Forget to normalize and IBKR rejects the order.
Time & sessions
4. UTC vs local time mixing
Cardinal rule: storage UTC, UI displays ET.
❌ Wrong:
from datetime import datetime
order.created_at = datetime.now() # naive local time — what timezone?!
✅ Right:
from datetime import datetime, timezone
order.created_at = datetime.now(timezone.utc)
5. Trying to MARKET-order outside RTH
IBKR (and most equity brokers) reject MARKET orders outside Regular Trading Hours. They want LIMIT.
# Bad — IBKR rejects pre-market MARKET
order = Order(order_type=OrderType.MARKET, extended_hours=True)
# Good — LIMIT with explicit price
order = Order(
order_type=OrderType.LIMIT,
limit_price=Decimal("180.50"),
extended_hours=True,
tif=TimeInForce.GTC, # if you want it to survive the session boundary
)
6. Forgetting daylight-saving transitions
NYSE opens at 9:30 ET = 13:30 UTC (winter, EST) or 14:30 UTC (summer, EDT). If you cron at 14:30 UTC, you'll be 1 hour late half the year.
✅ Use mts1b_platform.calendars.market_calendar("NYSE") for actual open/close, which handles DST automatically.
Idempotency
7. Using uuid.uuid4() as idempotency key
Every retry creates a new key, no dedupe happens, you submit duplicate orders.
❌ Wrong:
order = Order(idempotency_key=str(uuid.uuid4()), ...)
✅ Right — derive from inputs:
order = Order(
idempotency_key=f"{strategy_id}-{rebal_ts.isoformat()}-{symbol}-{side.value}",
...,
)
8. Idempotency keys shared across funds
If two funds run the same strategy and share idempotency keys, OMS treats them as duplicates.
❌ Wrong:
key = f"momentum-{date}-{symbol}-{side}"
✅ Right:
key = f"momentum-{fund_id}-{date}-{symbol}-{side}"
Money
9. Float for prices/quantities
Floats lose precision over arithmetic. Use Decimal:
❌ Wrong:
order.quantity = 100.0
order.limit_price = 180.50
✅ Right:
from decimal import Decimal
order.quantity = Decimal("100")
order.limit_price = Decimal("180.50")
Pydantic models in foundation use Decimal for all monetary fields. Pass strings — float construction can introduce precision errors:
Decimal("0.1") # exact: 0.1
Decimal(0.1) # imprecise: 0.10000000000000000555111512...
10. Forgetting fees in P/L
# Wrong — gross P/L
pnl = (exit_price - entry_price) * quantity
# Right — net of fees
pnl = (exit_price - entry_price) * quantity - entry_fees - exit_fees
mts1b_quantkit.cost_models.venue_cost_model knows venue-specific fee schedules.
11. Treating Coinbase taker/maker as a uniform fee
Coinbase Advanced charges different rates for maker (limit) vs taker (market):
| Tier | Maker | Taker |
|---|---|---|
| Default | 0.40% | 0.60% |
| Pro | 0.10% | 0.20% |
The Fill.fees field should reflect actual venue fees, not an estimate.
Risk gates
12. Loosening envelope to "get an order through"
Tempting when an order is being rejected: just edit the envelope. Don't. Especially during a drawdown halt.
The envelope tightening rule: mts1b-riskengine blocks any envelope change that would loosen a constraint while the fund is in halt state.
If you genuinely need to loosen, follow this:
mts cmd resume <fund_id>— operator action with co-sign- Edit envelope
- Submit order
- (Optional) re-halt if needed
13. Cancel raced a fill
await broker.cancel(order_id) # returns True
# ... but the order had already filled milliseconds before cancel
Result: position you didn't expect.
Fix: After cancel, ALWAYS call get_positions() and reconcile. mts1b-riskengine.broker_exit_reconciler does this every 120 seconds.
14. Synthetic stop without a quote feed
RiskEnvelope.enable_synthetic_stop = True requires a live quote feed. Without one, the stop never fires.
mts1b-riskengine polls quotes via mts1b-marketdata.MultiSourceProvider. If marketdata is down, alert and disable synthetic stops or fall back to broker-side stops.
Backtest
15. Look-ahead bias
Using future data to make a past decision. Subtle examples:
# WRONG — uses today's close to decide today's position
position_today = sign(close[t]) # close[t] not knowable until end of day
# RIGHT — uses yesterday's close
position_today = sign(close[t-1])
Standard practice: factor outputs are realized t-1 EOD; the order goes out at t open.
16. Survivorship bias
Universes that include only "currently exists" symbols are biased UP — losers got delisted.
✅ Use mts1b-datalake.equities.universe(asof=date) which knows the historical universe membership.
17. Zero costs in backtest
result = run_single(..., cost_bps=0) # looks great!
Then live: 2x worse than backtest. Always use realistic cost_bps:
| Asset class | Realistic cost_bps |
|---|---|
| US large-cap equities | 5-10 |
| US small-cap equities | 15-30 |
| Crypto (Coinbase taker) | 60 |
| FX | 5-10 |
| Options | 50-100 |
18. Insufficient walk-forward
A single backtest tells you nothing. Run walk-forward:
cv = walk_forward(
factor=get("f_my_factor"),
train_window=252, test_window=63, step=63,
start="2014-01-01", end="2024-01-01",
)
print(cv["fold_sharpes"]) # 40 numbers
print(cv["agg_sharpe"]) # 1.43
print(cv["stability"]) # 1.0 = identical across folds, 0.0 = random
If stability < 0.4, the factor is regime-dependent. Either filter for regime or drop the factor.
OMS / OMS-Algos
19. Mixing parent order quantity with child order quantity
Execution algos split a parent order into children. The parent has total quantity; each child has a slice. They MUST sum.
# In a VWAP execute() impl:
total = parent.quantity
sum_children = sum(child.quantity for child in slices)
assert total == sum_children
20. Using cancel to update an order
Brokers usually don't support "modify" via the same API. To change a price:
# Cancel old
ok = await broker.cancel(old_order.broker_order_id)
if not ok:
raise RuntimeError("could not cancel")
# Submit new
new_order = old_order.model_copy(update={
"limit_price": new_price,
"idempotency_key": f"{old_order.idempotency_key}-modify-{counter}",
"order_id": str(uuid.uuid4()),
})
await broker.submit(new_order)
Mind the race: cancel could fail because it raced a fill.
Treasury & multi-fund
21. Reusing tax-lot ID across funds
TaxLot(lot_id="lot-001", fund_id="fund-A", ...)
TaxLot(lot_id="lot-001", fund_id="fund-B", ...) # collision
Tax-lot IDs MUST be globally unique (not just per-fund). Use UUIDs or f"{fund_id}-{seq}".
22. Forgetting wash-sale rule
If you sell at a loss and rebuy within 30 days, the IRS disallows the loss (and adjusts cost basis of the new lot).
mts1b-treasury tracks wash sales across registered funds only. Register all your accounts — including IRAs — so the tracker can see them.
23. Multi-currency P/L: asset return ≠ currency return
If your fund's base is USD and you trade EUR equities:
TOTAL P/L (USD) = stock_return (EUR) × FX_translation + cash_in_EUR × FX_change
Don't conflate the two; use mts1b-reportslibrary.attribution.multicurrency.
Data lake
24. Reading without partition filters
# Slow — scans every parquet file in the lake
df = lake.equities.bars.read()
# Fast — pushes filter into the parquet reader
df = lake.equities.bars.read(start="2024-01-01", end="2024-06-01")
Always filter on start/end (or symbols) at the boundary.
25. Forgetting time-of-day for intraday
# Reads ALL intraday bars (millions of rows)
df = lake.equities.bars.read(symbols=["AAPL"], interval="1m")
# Reads just the last 6 months of 1m bars (still many)
df = lake.equities.bars.read(symbols=["AAPL"], interval="1m",
start="2024-01-01", end="2024-06-30")
If you only need RTH:
df = df.filter((df["ts"].dt.hour >= 9) & (df["ts"].dt.hour < 16))
NATS event bus
26. Forgetting the NATS-Msg-Id header
Without it, JetStream's built-in dedupe doesn't kick in. Duplicates pass through.
# Wrong
await js.publish("mts.v1.oms.orders.created", order.model_dump_json().encode())
# Right
await js.publish(
subject="mts.v1.oms.orders.created",
payload=order.model_dump_json().encode(),
headers={"NATS-Msg-Id": order.idempotency_key},
)
27. Slow consumer kills the stream
JetStream drops consumers that fall behind. Use:
sub = await js.subscribe(
"mts.v1.>",
durable="my-consumer",
max_ack_pending=1000, # don't queue more than 1000
ack_wait=10.0, # ack within 10s
)
28. Subscribing without durable=
# Loses messages on restart
sub = await nc.subscribe("mts.v1.oms.>")
# Survives restart
sub = await js.subscribe("mts.v1.oms.>", durable="my-consumer")
LLM (mts1b-llm)
29. Prompts including too much history
# Wrong — passes entire 100k-char conversation
await llm.complete(prompt=conversation_log, persona="analyst")
# Right — pass relevant summary
summary = summarize_recent(conversation_log, last_n=10)
await llm.complete(prompt=summary, persona="analyst")
30. Treating LLM output as authoritative
LLM is advisory. A human or deterministic gate makes the final call.
# Wrong — auto-trade on LLM output
veto = await cro.veto_order(order)
if veto.veto:
return reject(order)
# Better — log + alert, gate by confidence
if veto.veto and veto.confidence > 0.85:
return reject(order)
elif veto.veto:
log.warning("CRO suggested veto but confidence low", extra={"order_id": ...})
Deployment
31. Cutover during RTH
Don't do it. Always cutover outside trading hours. NYSE 4 AM ET is the conventional safe window.
32. Cutover on a Friday
Production debugging on Saturday morning is a special kind of pain. Cutover Monday-Thursday only.
33. Forgetting to test the rollback before cutover
mts1b-deploy rollback --to <prior_commit> --dry-run
If the dry-run shows changes you don't expect, fix before cutover.
See also
- Troubleshooting — what to do when these go wrong in production
- Design principles — the non-negotiables that prevent some of these
mts1b-riskengine— many gates exist BECAUSE of these pitfalls