PitBridge
Join the waitlist

Guardrails and safety

AI trading bot failure modes, and the control that catches each

Most writing about AI trading risk stays abstract. This is the concrete catalog: the specific ways an agent with order access goes wrong, from a duplicate submit on retry to a position book that quietly diverged from the broker, and for each failure the deterministic control that catches it, as actually built in PitBridge's executor, guardrail chain, and reconciler. These controls reduce named failure classes. They do not make trading safe, and they do not make it profitable.

On this page

Ask how an AI trading agent fails and most answers gesture at “hallucination” and move on. That is not an engineering answer. An agent with order access fails in specific, mechanical, reproducible ways, the same ways distributed systems fail, plus a few the model contributes, and each one can be named, provoked on a simulation account, and caught by a specific control. This piece is that catalog. The companion piece, risk controls an LLM cannot override, walks the controls first; this one walks the failures first, because that is the direction you actually experience them in. Everything below describes how PitBridge is really built, in the open core you can read, and none of it makes trading safe or profitable. It makes specific failures non-fatal.

The catalog at a glance

Failure modeWhat catches itReason code or mechanism
Wrong size, side, or instrumentschema check, allowlist, position capsINVALID_QTY, INSTRUMENT_NOT_ALLOWED, MAX_CONTRACTS_PER_ORDER, MAX_POSITION
Duplicate order on retryduplicate window, then idempotent client_order_idDUPLICATE_ORDER; duplicate ids refused, duplicate events no-op
Deciding on stale datalink and feed freshness guards, fail closedLINK_DOWN, STALE_ACCOUNT_STATE
Runaway order looprate limits and cooldownsMAX_ORDERS_PER_MINUTE / _HOUR / _DAY, COOLDOWN_AFTER_LOSS, COOLDOWN_AFTER_ORDER
Prompt injection via market textdeterministic engine outside the model, absent toolsno tool can lift a limit, release the kill, or arm live
Position drift after reconnectbroker-truth reconcile, in-flight-aware caps, persistent ledgerposition_mismatch audit entries, CANCELLED on absence
All of the above at oncekill switch with reduce-only carve-outKILL_SWITCH, release is human-only

Two properties hold for every row. The control runs in the daemon, a separate process the model has no path into, so no output the model produces can route around it. And every decision, allow or block, lands in an append-only, hash-chained audit log, so each failure mode below is also inspectable after the fact with pitbridge audit why <order_id>.

Wrong size, wrong side, wrong instrument

The base failure. A language model produces a distribution of outputs, and the tail of that distribution includes qty=10 where you meant 1, SELL where it read BUY, or NQ when your plan says MES. No malice required, just one bad sample at the wrong moment.

The catch is layered before any account state is consulted. The schema stage rejects a malformed request outright, including a zero or negative quantity, which matters more than it looks: a negative BUY is a disguised SELL that would sail past a positive per-order cap if it were allowed to exist. The instrument_allowlist refuses any contract you did not explicitly list. The per-order cap blocks a single oversized order with MAX_CONTRACTS_PER_ORDER, and max_position blocks any order whose projected net position would breach your cap. All of it is deterministic: the same order and the same config produce the same decision every time, with no model anywhere in the adjudication. The full set of twelve controls and their frozen evaluation order is in the guardrails piece; this article stays on the failure anatomy.

Duplicate orders on retry

The classic. The agent, or the client library under it, submits an order and hears nothing back inside its timeout. It concludes failure and retries. But the first order was not dead, it was slow, and now two live orders express one intent. Retry-on-ambiguity is correct behavior for idempotent APIs and catastrophic for order entry, and an LLM agent makes it worse: models are literally trained to try again when something looks like it failed.

PitBridge treats this as two failures needing two lines of defense at different depths. The first is the duplicate_protection guardrail: a repeat of the same instrument, side, and quantity on the same account inside duplicate_window_s seconds blocks with DUPLICATE_ORDER. That catches the plain double-submit before it exists.

agent tool calls
place_order account=sim instrument="MES 09-26" side=BUY qty=2  -> SUBMITTED, then FILLED filled_qty=2
# the agent's client times out waiting and replays the same intent
place_order account=sim instrument="MES 09-26" side=BUY qty=2  -> BLOCKED reason_code=DUPLICATE_ORDER

Real tool name and reason code; the window is duplicate_window_s in your config (the scaffolded config uses 30 seconds). Fills are synthetic simulation fills.

The second line is idempotency at the order’s identity. Every order intent gets a daemon-generated client_order_id, a UUID that becomes the NinjaTrader Order.Name and serves as the single idempotency key for the order’s whole lifecycle. The executor refuses a duplicate id outright. Inbound broker events are applied idempotently: a duplicate delivery of the same order update is a no-op, a fill is deduplicated by its execution id, and an order that reached a terminal state is never resurrected by a late or repeated frame.

The subtle case is the timeout itself. If no broker ack arrives within the ack window (10 seconds by default), the order is marked TIMED_OUT, and that status is deliberately not treated as final. A timeout means we did not hear an ack, not that the order died; it may be live at the broker. So a TIMED_OUT order stays eligible for reconciliation, and the next snapshot either adopts it in its true broker state or confirms it is gone. The honest engineering move is to keep the ambiguity visible and resolve it against broker truth, not to guess.

Deciding on stale data

An agent reads day_pnl: 0.0 and sizes up. The number was true four minutes ago; the feed died, the loss since then is real, and every control keyed on P&L is now reasoning about a world that no longer exists. Stale data does not look like a failure. That is what makes it one.

The control is freshness enforcement that fails closed, in two layers. Heartbeats run every 5 seconds in both directions on the AddOn link, and after 15 seconds of silence the link is DOWN: every order hard-rejects with LINK_DOWN, because queueing orders against a dead link is how you end up exposed when you thought you were covered. Subtler: the link can be up, heartbeats flowing, while a single account’s account_state feed, the source of its unrealized P&L, has gone silent. Past account_state_staleness_s (default 60 seconds, floor 5) new entries block with STALE_ACCOUNT_STATE, so the daily loss halt and profit lock fail closed instead of deciding on frozen numbers. De-risking with cancel, close, and flatten stays allowed in both cases. And after any reconnect, the link is not considered fresh until the first account_state actually arrives for the account; a snapshot alone does not unfreeze order flow. The 0.2.1 changelog entry records why this guard was added: the failure was found by review, not by a blown account, which is the cheap time to find it.

Runaway loops

Agents run loops: plan, act, observe, repeat. A loop with a bad termination condition, a strategy that re-fires on every tick of the same signal, or a model that keeps concluding “that did not work, try again” produces order flow no human would. This is the failure that turns one mistake into forty.

The controls are rate and rhythm caps that no loop can reason its way past. max_orders caps order count per minute and per hour on rolling windows, and per day on the ET calendar date, the same day boundary the ledger uses. cooldown_after_order_s enforces minimum spacing between orders; cooldown_after_loss_s blocks the revenge-trade rhythm by enforcing a pause after any losing execution. Each returns its own reason code (MAX_ORDERS_PER_MINUTE, COOLDOWN_AFTER_LOSS, and kin), so a blocked loop is also a legible loop: the agent reads why it stopped instead of interpreting the block as one more failure to retry. And if a loop finds a rhythm that fits inside all of those caps, the kill switch stops everything at once, from outside the loop.

Prompt injection through market text

A trading agent reads text as part of the job: headlines, economic calendar notes, news summaries, a research file you gave it. Any of that can carry adversarial instructions, and the model has no reliable way to separate untrusted text from your intent, because both travel the same channel. This is not the coding-agent edge case where a poisoned README is a curiosity; here the untrusted text arrives through the data pipeline you built on purpose, at market speed. Prompt hygiene helps and is not a control: every prompt-level defense is probabilistic, and the run where injected text wins is the run that counts.

The control is structural. Whatever the model was talked into proposing, the proposal lands in a deterministic engine in a separate process, checked against limits from a config file the agent has no tool to edit. And the tool surface is the stronger half of the argument: the agent’s nine tools contain no function to raise a limit, release the kill switch, or arm live trading, so the worst case of a fully hijacked session is a proposal that still has to clear every check. The blast radius is a BLOCKED result and an audit entry. How that absence-of-capability design works end to end is covered in risk controls an LLM cannot override.

Losing track of the position after a disconnect

The deepest failure on the list, and the one bot frameworks most often get wrong. A bot that builds its position view purely from the events it observes is running dead reckoning: every dropped frame is silent drift between its book and reality. Disconnect for thirty seconds during a fill, and the bot believes it is flat while holding two contracts. Restart the process mid-session and the day’s realized loss resets to zero, which quietly disarms the daily loss halt. The agent then acts, confidently, on fiction.

PitBridge’s rule is that the broker is the source of truth and the daemon’s book is a cache. On every connect and reconnect, the daemon requests a snapshot and adopts the broker’s positions over its own book, flattening any instrument the book thought it held that the snapshot omits. Every divergence is written to the audit log as a position_mismatch entry with both quantities, so drift is loud instead of silent. In-flight orders are reconciled against the broker’s working orders: an order the broker knows about is adopted in its broker state, and an order the broker does not know about is marked CANCELLED with the reason recorded. Nothing is ever auto-resubmitted, because a resubmit is a guess, and snapshots are applied per account, so one account’s snapshot can never cancel another account’s in-flight orders.

audit.jsonl, after a reconnect
{"type":"position_mismatch","account":"Sim101","instrument":"MES 09-26","book_qty":3,"broker_qty":2}
{"type":"reconcile","account":"Sim101","changes":{"ord_7f3a":"CANCELLED"}}

Real entry types and fields as the daemon writes them, abridged from the full hash-chained record. The mismatch is booked and visible, never silently corrected.

Two more pieces close the loop. The max_position cap is in-flight-aware: it counts the unfilled remainder of the account’s own working and pending orders, signed per instrument, alongside the filled position, so two orders that each fit the cap cannot sum past it by racing each other, and a reducing order is netted rather than double-counted into a false block. And the day ledger, the day’s realized P&L, the sticky halt latch, and the set of booked execution ids, persists to disk and is restored on startup for the current trading date, so a mid-session restart no longer forgets the day’s losses; a prior day’s ledger rolls over and is never resurrected. Both hardenings are recorded plainly in the 0.2.1 and 0.2.2 changelog entries, including what was wrong before them.

The backstop: one switch that stops all of it

Every control above targets one failure class. The kill switch targets the situation where you have stopped caring which class this is. Engaged, it blocks every new entry with KILL_SWITCH, checked first in the chain, before any other logic runs. It is file-backed, so it survives a daemon restart and can be engaged with a bare file touch even if the daemon is wedged. Cancel, close, and flatten keep working under it, so stopping new risk never traps you in existing risk. And release is a human act at the CLI, deliberately absent from the agent’s tools and from the REST surface. What that switch must guarantee to deserve the name has its own deep dive.

What this catalog does not claim

Honesty about scope is part of the engineering. Each control above reduces one named failure class: duplicates, stale-data decisions, runaway loops, oversized or off-plan orders, silent position drift. Here is what none of them do. They do not evaluate whether a trade is wise. They do not predict markets, and an order that passes every check can lose money, because being inside your limits and being right are different properties. They do not run unless you configure them: a guardrail with no value set does not check anything, and the values are your judgment, not ours. And the catalog is not finished. The in-flight race and the stale-feed hole were found by review after v0 shipped and closed in 0.2.1; the restart amnesia in 0.2.2. There will be a next one, which is exactly why every decision is audited and the changelog names what was wrong.

Honest status: paper by default, live gated

Everything in this piece runs against paper and simulation accounts today. In the open core mode = "live" is intentionally out of reach: arm-live refuses, because live execution ships separately as a paid, closed component, and there is no agent tool or API route to change that. Live execution has been in production on a funded futures account since 30 July 2026, behind an operator-run arm step. For an engineer evaluating the failure modes above, that is the useful part: every reason code and every reconcile path in this article can be provoked and observed on a Mac with the bundled fake AddOn, with nothing at risk, before any real account is in scope.

If you are building an agent with order access, the trading AI guardrails pillar covers why these controls must live outside the model, the guardrails page lists the full set with reason codes, and the security model shows where each part runs. If you want this failure catalog standing between your agent and your account, tell us your platform on the waitlist. PitBridge is trading infrastructure, not financial advice: it enforces the limits you configure and does not promise any trading outcome. Futures trading carries a substantial risk of loss.

Read the pillar: Trading AI guardrails

Questions

What are the main failure modes of an AI trading bot?

The recurring classes are: an order with the wrong size, side, or instrument; duplicate orders from a retry after a timeout; decisions made on stale account data; runaway order loops; a context hijacked by prompt injection in text the agent reads; and a position view that diverged from the broker after a disconnect or restart. Each one is mechanical, reproducible, and addressable with a specific control outside the model.

How do trading bots end up placing duplicate orders?

Almost always through retry-on-timeout. The bot submits, hears nothing inside its timeout, assumes failure, and submits again, but the first order was alive at the broker all along. PitBridge layers two defenses: a DUPLICATE_ORDER guardrail that blocks a repeat of the same instrument, side, and quantity inside a configured window, and an idempotency layer where every order carries a daemon-generated client_order_id, duplicate ids are refused, and duplicate broker events are no-ops.

What is an idempotent client order id?

A unique id the daemon generates per order intent, in PitBridge a UUID that becomes the NinjaTrader Order.Name, used as the single idempotency key for the order's whole lifecycle. The executor refuses to submit the same id twice, applies duplicate status events as no-ops, deduplicates fills by execution id, and never resurrects an order that reached a terminal state from a late or repeated frame.

Can prompt injection make an AI trading agent place bad orders?

It can steer what the agent proposes. A trading agent reads text as part of the job, headlines, news summaries, research notes, and any of it can carry adversarial instructions the model cannot reliably separate from yours. The control is structural, not prompt hygiene: every proposed order is adjudicated by a deterministic engine outside the model, and the tool surface contains nothing that can change a limit, release the kill switch, or arm live trading. A hijacked session can propose; it cannot exceed.

How does a trading bot lose track of its position?

By building its position view from the event stream and then missing events: a dropped connection loses fills, a restart loses in-memory state, and the bot then acts on a position that no longer exists. PitBridge treats the broker as the source of truth: on every reconnect it requests a snapshot, adopts the broker's positions over its own book, writes any divergence to the audit log as a position_mismatch entry, and marks in-flight orders the broker does not know about as CANCELLED rather than guessing or resubmitting.

Do these controls make AI trading safe or profitable?

No, and it is important to say so plainly. Each control reduces one named failure class: duplicates, stale-data decisions, runaway loops, oversized orders, silent position drift. None of them evaluates whether a trade is wise, none predicts the market, and an order that passes every check can still lose money. Futures trading carries substantial risk of loss. This is not financial advice.

PitBridge is in development. NinjaTrader 8 is first.

Tell us your platform and we email you when your setup is supported. Nothing else.