Skip to main content

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):

TierMakerTaker
Default0.40%0.60%
Pro0.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:

  1. mts cmd resume <fund_id> — operator action with co-sign
  2. Edit envelope
  3. Submit order
  4. (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 classRealistic cost_bps
US large-cap equities5-10
US small-cap equities15-30
Crypto (Coinbase taker)60
FX5-10
Options50-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