What our bot actually does on a tick
A tour of the eleven things that happen between “new bar arrived” and “order submitted” — useful if you’re building your own bot, debugging ours, or just curious.
People ask us “what does the bot do?” expecting either a one-liner (it trades stocks!) or a wall of math. The honest answer is in between: a sequence of about eleven steps that run on every fresh bar, each individually simple, composing into something that's actually hard to break in production. Here's the tour.
The trigger
The bot doesn't poll the market on a clock. It subscribes to a bar stream from the broker (Alpaca, IBKR) and the market data API pushes a finalized bar at the end of each interval — 1m, 5m, 1h, or 1d depending on your config. That's the “tick.” Everything in this post happens in the ~200ms between bar-arrival and the next one.
1. Bar validation
Brokers occasionally send malformed bars: zero volume, NaN prices, timestamps from yesterday because of a backfill. The bot rejects those at the door and emits an erroraudit event so the user sees it on the Live tab. Trades aren't made on bad data.
2. Feature computation
The bar gets appended to a rolling window (default ~900 bars) and ~15 features are computed: log returns over multiple horizons, realized volatility over 5/20/60 bars, autocorrelation, dispersion, gap statistics, EMA/SMA crossovers, ATR. These are the inputs to the HMM.
3. Regime classification
The features go through the HMM forward pass. Output: a probability distribution over the K hidden states (typically 7 for daily models, K determined by BIC during training). The bot also tracks a stability counter — how many bars the same regime has been the argmax — and a flicker counter for how often regime swapped in the last N bars.
4. Regime confirmation gate
A new regime label isn't acted on immediately. The bot requires:
- Confidence ≥ minimum (default 0.60 — meaning the argmax regime has at least 60% probability mass).
- Stability ≥ minimum (default 3 bars in the same regime).
- Flicker count ≤ threshold (the regime hasn't been ping-ponging).
Until those gates clear, the bot uses the previous confirmed regime — even if the live argmax has switched. This prevents whipsaws on noisy bars.
5. Strategy selection
Each regime maps to a strategy class: defensive (small or no exposure), fully-invested (max long), trend-follow, breakout, etc. The map is part of the model configuration. Selecting a strategy is a dictionary lookup; the heavy lifting was in fitting the regime → strategy mapping during research, not at runtime.
6. Symbol screening
For multi-symbol bots, every active symbol is screened through the strategy: trend filter, liquidity filter, volatility budget. Symbols that fail are skipped this tick. Symbols that pass go to step 7.
7. Position-size calculation
For each symbol that passed screening, the bot computes a target dollar exposure based on:
- Account equity (live, from the broker, refreshed each tick).
- Regime's allocation cap (e.g. DEEP_BEAR allows max 60% gross exposure; TOP_BULL allows 100%).
- Per-symbol position cap (e.g. max 5% of equity in any single name).
- ATR-scaled risk budget (riskier symbols get smaller positions, even within the same allocation cap).
8. Risk-cap pre-check
Before submitting, the proposed order is checked against the live state of every risk cap:
- Daily drawdown halt — if the account is down ≥ X% today, new entries are refused and existing positions tightened.
- Weekly DD halt, peak-to-trough DD halt.
- Total leverage limit — gross long + short can't exceed configured max.
- Per-asset-class caps — crypto exposure ceiling, etc.
- Trades-per-day cap — if you've already done N trades, no new entries until tomorrow.
If any cap fails, the order is dropped with a circuit_breaker audit event and a clear reason.
9. Duplicate-entry guard
The bot won't add to a position it already holds, even if the strategy says to. Stop-loss + take-profit handle exits; re-entries require the existing position to close first. This sounds obvious but it took us debugging a position- ladder bug in week one to convince ourselves it's the right default.
10. Order submission
Survivors get submitted to the broker (limit order at the bar close ± buffer for slippage tolerance). The bot waits up to 5 seconds for an ACK; if the broker rejects, an order_rejected audit event captures the reason (insufficient buying power, market closed, asset not tradable, etc.) and the order is dropped.
11. State snapshot
After all that, the bot writes its updated state to disk: open positions, today's P&L, the last bar processed, the model's current regime. The snapshot is atomic (write to .tmp+ rename) so a crash mid-write can't corrupt state. On next boot, the bot resumes from this snapshot — no manual recovery, no lost positions.
# Roughly the loop body, with non-essential plumbing trimmed.
def on_bar(bar):
if not validate(bar):
emit("error", reason="bad_bar")
return
features = engineer.compute(rolling_window + bar)
state_probs = hmm.forward(features)
regime = pick_regime(state_probs, stability, flicker)
if not regime.is_confirmed:
return # nothing to do — wait for confirmation
strategy = orchestrator.regime_to_strategy[regime.id]
for symbol in active_symbols:
if not strategy.screens(symbol):
continue
size = sizer.compute(account, regime, symbol)
if not risk_manager.allows(size, account):
emit("circuit_breaker", reason=risk_manager.last_breach)
continue
if positions.has(symbol):
continue # duplicate-entry guard
order = build_order(symbol, size)
ack = broker.submit(order)
emit("order_submitted" if ack.ok else "order_rejected", ...)
snapshot.save()What we don't do
A few things that look like they should be in the loop but aren't:
- Retrain the model.That happens on a schedule (hourly check, daily retrain, configurable), not every tick. Retraining is expensive and the regime structure doesn't change minute-to-minute.
- Re-fetch market data we already have. The bar stream is incremental; we keep the rolling window in memory and just append.
- Consult the news / sentiment / Twitter. Adding extra signal sources is its own research project. Right now the bot is honest about being a regime classifier with risk-aware sizing — nothing more.
That's the loop. Eleven steps, ~200ms per bar, all observable on the dashboard's Live tab as audit events as they happen. If you're curious what your specific bot is doing right now, that's the place to watch.