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.