From backtest to live: the strategy state machine

A backtest's loop ("for each bar: check signal, update position") maps poorly onto a live bot, which must react to asynchronous events (ticks, order fills, timeouts) rather than iterating a fixed dataset. This chapter is the pattern that bridges the two honestly.

Explicit states, not implicit flags scattered through code

from enum import Enum, auto

class PositionState(Enum):
    FLAT = auto()
    ENTRY_PENDING = auto()
    OPEN = auto()
    EXIT_PENDING = auto()
    CLOSED = auto()

class StrategyStateMachine:
    def __init__(self, order_manager, price_cache):
        self.om = order_manager
        self.price_cache = price_cache
        self.state = PositionState.FLAT
        self.entry_order_id = None
        self.exit_order_ids = []
        self.position = None

    def on_signal(self, signal: int, params: dict):
        if self.state == PositionState.FLAT and signal != 0:
            self._enter(signal, params)
        elif self.state == PositionState.OPEN and signal == 0:
            self._exit()

    def _enter(self, signal: int, params: dict):
        side = "BUY" if signal == 1 else "SELL"
        result = self.om.place(strategy_code=params["code"], transaction_type=side, **params["order_kwargs"])
        if result["success"]:
            self.entry_order_id = result["order_id"]
            self.state = PositionState.ENTRY_PENDING
        else:
            logging.warning(f"Entry failed: {result['reason']}")   # stay FLAT, don't advance state on failure

    def on_order_update(self, update: dict):
        if update["order_id"] == self.entry_order_id and update["status"] == "COMPLETE":
            self.position = {"qty": update["filled_quantity"], "avg_price": update["average_price"]}
            self._attach_exits()
            self.state = PositionState.OPEN
        elif update["order_id"] in self.exit_order_ids and update["status"] == "COMPLETE":
            self._cancel_sibling_exits(update["order_id"])
            self.state = PositionState.CLOSED
            self.position = None

Why this matters: it forces every transition through validated fill confirmation

Notice _enter does not move to OPEN — only on_order_update does, and only on confirmed COMPLETE status. This structurally prevents the chapter 47 bug class (placing dependent exit orders before the entry is confirmed filled) — it's not possible to reach _attach_exits() except from a genuine fill confirmation, because the state machine's transitions are the only path there.

Backtest and live share the *decision* logic, diverge only at execution

def strategy_decision(price_data, current_state) -> int:
    """Pure function: same code path for backtest AND live.
    Returns -1/0/1 — the execution layer (state machine above, or vectorized backtest) differs."""
    return opening_range_breakout_signal(price_data)  # chapter 80

The signal function (chapter 80) stays a pure function called identically by both the vectorized backtest and the live state machine's on_signal — only the *execution* mechanics differ (simulated fill vs. real order + state transitions). This is what makes chapter 87's "same code path" paper trading claim actually true, rather than aspirational.

Persistence — the state machine must survive a restart

Serialize state, entry_order_id, exit_order_ids, and position to disk on every transition, and reload on startup, reconciling against the broker's actual order/position state (chapter 66) rather than assuming the in-memory state is still accurate after any restart.

Next: 089 — Logging and trade journaling