Skip to main content

The engine

tradeflow/engine/ contains two orchestrators. They wire the other layers together and own the per-bar loop — but contain no indicator math, no metric formulas, and no vendor specifics.

BacktestEngine

run(symbols, start, end, initial_capital, trade_from=None) -> BacktestResult:

  1. Fetch bars for all symbols via the MarketDataClient.
  2. _prepare: per symbol, process_datagenerate_signalscalculate_scores, then align every symbol to one merged timeline (_Panel).
  3. _replay: walk that timeline once against a shared capital pool.
  4. Compute metrics (analytics.performance) and return a BacktestResult.

trade_from separates warmup from trading: earlier bars feed the indicators but open no positions, and the equity curve starts there. Walk-forward uses it so an out-of-sample window is measured on its own portfolio curve.

Portfolio accounting

The simulation runs on one clock against one capital pool — every symbol on a single merged timeline, positions competing for the same dollars as they would live. Each step:

  1. Decide on the previous bar. A signal at bar i comes from calculate_scores at bar i, and scores are computed from that bar's close. So the signal a bar may act on is always the one before it, executed at this bar's open. Through accounting v3 the engine executed signal[i] at open[i] — a one-bar look-ahead on every entry and every signal exit, applied to the whole history. A feed shift cannot detect it: shifting moves signal and price together, so the relationship survives intact, which is why the leakage probe passed over it for as long as it did.
  2. Mark open positions at this bar's open, and track excursion extremes over the whole bar. The open, because every decision below transacts at it: the exposure caps in step 4 are tested against equity × cap, so marking at the close first meant whether an entry was admitted could depend on where the bar finished, hours after the price it filled at. That was accounting v4's remaining intra-bar look-ahead — the same class as the signal one above, in the admission gate rather than the signal, and just as invisible to a feed shift. The excursion extremes stay full-bar: they are reported, never consulted by a decision, and the worst and best a position saw is the point.
  3. Exit — stop-loss, take-profit, then signal exit, in that order. Stop/take fill at their level; a signal exit fills at the next open. Exits run before entries, so capital freed this bar is reusable this bar.
  4. Rank entry candidates across the whole universe by the strategy's own conviction score, descending, ties broken by symbol so a run is reproducible.
  5. Admit in that order while max_positions, max_total_risk, max_gross_exposure, max_net_exposure and free cash allow. Sizing goes through Strategy.calculate_position_size as before, but against free cash, which is what makes positions actually compete. The two portfolio fractions are not the same measurement — see what max_total_risk caps.
  6. Record portfolio equity — cash plus positions re-marked to this bar's close. Everything that transacts on the bar has happened by now, so the curve is an end-of-bar mark-to-market as it has always been. Only the decisions are held to the open, and only because they priced there.

Anything still open at the end is force-closed (END_OF_PERIOD). P&L is (exit − entry) × size × direction, less costs on both legs.

Shorts are fully cash-collateralized. Opening debits the whole notional whichever way the position faces, rather than crediting short proceeds against margin the way a real margin account would. This is deliberate: a short costs the same buying power as the equivalent long, so the book can never quietly take on leverage the engine isn't modelling. The trade-off to keep in mind when reading results is that short capacity is understated, so a long-short configuration and a long-only one are not compared on exactly equal footing. Entry and exit are symmetric, so realized P&L is unaffected.

Annualizing per-step quantities

The merged timeline is the union of every symbol's timestamps, so it is at least as dense as any single symbol's bars and strictly denser whenever symbols don't share one grid — halts, differing listing calendars, a mixed-venue universe. Two things are measured in steps on that timeline: the equity curve and short carry accrual.

Annualizing them at the strategy's timeframe rate would therefore assume a coarser sampling frequency than the series actually has, inflating Sharpe and volatility by √density and understating carry by density. The engine instead scales the timeframe rate by the observed density (merged steps ÷ the densest symbol's bars). For a universe whose symbols share a grid that ratio is exactly 1, so ordinary backtests are unchanged; it corrects only the ragged case, which was previously wrong in the flattering direction.

This matters more than it sounds. The engine originally simulated each symbol independently across its whole history and summed the P&L onto one capital base — so symbol A's positions returned their capital before symbol B started, and two positions that would have competed never met. Absolute metrics therefore scaled with universe size: the same strategy on the same window returned 23% on one symbol and 411% on fourteen. Position limits were per-symbol too, so max_positions: 1 meant one position per name, not one in the book.

Because the promotion gates and the research agent's out-of-sample selection both read those numbers, widening the universe was an undocumented way to make any strategy look better — the exact overfit surface the engine exists to close.

The equity curve is emitted from portfolio state per bar, so open positions are marked to market. It previously accumulated realized P&L at exit time and resampled to calendar days, which made a long-held position invisible until it closed and then land as a single spike — overstating volatility and distorting Sharpe, drawdown and VaR. See the gate calibration for how that measurement change was carried into the thresholds.

BacktestResult carries metrics, the trades DataFrame, the equity_curve, capital, dates, and the strategy config.

LiveEngine

start(symbols):

  1. _warm_up — fetch a lookback window, run process_data, and seed each symbol's rolling buffer via Strategy.warm_up, so indicators are valid on the very first live bar. The window is measured in sessions, not wall-clock time: converting bars to a timedelta directly counts the overnight gap and the weekend as tradeable, which at intraday frequencies fetches a fraction of the history the indicators asked for. A short or empty warm-up is logged, because from inside the loop it is indistinguishable from a quiet market.
  2. _cold_start — hydrate the strategy's position book from broker truth before the first bar. Warm-up seeds indicators; it says nothing about what is held, and a strategy that wrongly believes it is flat cannot emit an exit.
  3. Subscribe to the live stream through the MarketDataClient.
  4. _on_bar — feed each full streamed bar to process_bar; forward any actionable signal to the LiveTrader, which returns a Decision recording what it did and why.
  5. If the broker supports it, run the trade-update stream concurrently so fills/cancels/rejects are logged alongside trading, and each fill re-reads its own symbol from the broker — a reconciliation sweep landing between an entry's submission and its fill would otherwise leave the book believing it is flat in a symbol it holds, and a strategy that believes it is flat cannot emit an exit.

Inside the loop, on a timer rather than per bar, the engine re-reads the position book and reconciles the ledger against the account. Both are bounded by construction — one list_positions call per sweep, never one per symbol — because this runs on the trade clock and must not scale with universe size.

The broker SDK is synchronous, so its calls run in a worker thread. An entry makes several blocking round trips, and running those on the event loop stalls everything else it carries: other symbols that signalled on the same bar, the trade-update stream, and the reconciliation sweep. There is still exactly one loop and one order at a time — everything that reads the position book and then acts on it is serialized behind a single semaphore. That is a correctness requirement, not a speed-up: two entries checking the book at once can both pass an exposure limit only one of them fits inside.

Both live streams (market data and trade updates) auto-reconnect with capped backoff via a shared run_with_reconnect helper. Shutdown is signal-driven — SIGINT and SIGTERM take the same path — and every bound in it is deliberately duplicated off the loop, because third-party code that blocks the loop takes every loop-scheduled timeout down with it, signal handlers included. See stopping a session.

This matches the live path rather than being more conservative than it. Live has always been causal — a closed bar arrives, process_bar emits a signal, and a market order fills strictly afterwards — so through v3 the backtest was transacting at a price live could never get, and every validated number overstated what a deployment could achieve.

That is the general hazard of the two-clock design: the same rule is implemented twice, in code that cannot reference itself, so a defect on one side is a defect on the other until someone checks. See separation of concerns.

The engine never calls the broker directly — it delegates to execution. That boundary is exactly why the same strategy object backtests and trades live unchanged.