Building the Backtester Part 2 — A Strategy Engine That Matches TradingView Semantics
"An engine that fills at the close of the bar the signal fires on is not a backtester. It is a machine for producing equity curves that would have been great if you had a time machine. Every honest engine spends most of its complexity refusing to do exactly that."
In post 3 the data layer landed — three files, UTC-indexed OHLCV, Parquet-cached. This post fills the second of the four architectural layers: the strategy engine. By the end of it there is a working engine/ package plus one end-to-end example — examples/01_sma_cross.py — that produces a trade list and equity series comparable, bar for bar, to what TradingView's Strategy Tester would produce on the same rules.
The engine is four small pieces: a broker that fills orders on the next bar's open with commission and slippage, a portfolio that tracks position and equity, a Strategy base class that gives your idea one hook to plug into, and a runner that iterates the whole thing bar by bar. Together they clock in at under three hundred lines. That is not because a backtest engine is easy to write. It is because most of the interesting choices are about what the engine refuses to do — and that turns out to be a small number of lines each.
📋 What we will look at
- The four pieces of the engine, at one page
- The broker — next-open fills, commission, slippage as an adverse tick
- The portfolio — position, cash, equity, all bar-marked
- The Strategy base class and the
on_barhook - The runner — a deliberately boring bar-by-bar loop
- End-to-end — running
examples/01_sma_cross.pyon SPY 1D - Reality check — four ways the engine still lets you fool yourself
1. The Four Pieces of the Engine, at One Page
🔼 Figure 1: the four pieces of the engine. Each box is one module in engine/. The arrows are the calls the runner drives on every bar — the whole loop is intentionally small enough to hold in one page.
The design goal — restated from post 2 — is to match the semantics of TradingView Strategy Tester's default fill model: signal on bar N, fill on the open of bar N+1, commission on both legs, one adverse tick of slippage per fill. Everything below is in service of that specification. The instant a change tempts you to fill at the close of bar N "just for a smoke test", you are no longer building the same engine.
2. The Broker — Next-Open Fills, Commission, Slippage as an Adverse Tick
The broker is the smallest of the four pieces and the most opinionated. It has one public entrypoint — fill_market_order — and it deliberately does not know what a strategy is. It knows about bars, orders, positions, and fees.
# backtester/engine/broker.py
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
@dataclass
class Fill:
timestamp: pd.Timestamp
side: str # "buy" or "sell"
qty: float
price: float # after slippage
commission: float
@dataclass
class BrokerConfig:
commission_per_unit: float = 2.0 # $ per contract / share round-trip half
slippage_ticks: int = 1
tick_size: float = 0.25 # MNQ default; override for other symbols
class Broker:
def __init__(self, cfg: BrokerConfig | None = None):
self.cfg = cfg or BrokerConfig()
def fill_market_order(self, side: str, qty: float,
next_bar_open: float,
next_bar_ts: pd.Timestamp) -> Fill:
slip = self.cfg.slippage_ticks * self.cfg.tick_size
if side == "buy":
price = next_bar_open + slip
elif side == "sell":
price = next_bar_open - slip
else:
raise ValueError(f"Unknown side: {side}")
commission = self.cfg.commission_per_unit * qty
return Fill(timestamp=next_bar_ts, side=side, qty=qty,
price=price, commission=commission)
Three deliberate simplifications. First, only market orders exist. Limit and stop orders are one of the natural first extensions but they are also the ones that most easily leak look-ahead — a naïvely modelled stop that "fills at exactly the stop price" is quietly optimistic. Adding them properly requires an extra bar's worth of book modelling and is a later post. Second, slippage is always adverse. There is no coin flip, no distribution — one tick against you, every time. This is generous compared to what really happens on thin instruments and it is at least honest about the direction of the error. Third, commission is charged per unit per leg, not per trade. When the strategy opens three MNQ contracts and closes three, the broker charges $12 total ($2 × 3 × 2 legs), which matches the round-trip number used across the rest of this blog's position sizing framework.
3. The Portfolio — Position, Cash, Equity, All Bar-Marked
The portfolio is the piece of state that carries across bars. It holds one number (position size) and updates two more (cash and equity) on every bar. There is no P&L attribution, no per-strategy accounting, no multi-asset weighting — those live in the metrics layer. Here we just track the balance sheet.
# backtester/engine/portfolio.py
from __future__ import annotations
from dataclasses import dataclass, field
import pandas as pd
from .broker import Fill
@dataclass
class Portfolio:
initial_cash: float
cash: float = field(init=False)
position: float = 0.0
entry_price: float = 0.0
equity_curve: list[tuple[pd.Timestamp, float]] = field(default_factory=list)
fills: list[Fill] = field(default_factory=list)
def __post_init__(self):
self.cash = self.initial_cash
def apply_fill(self, fill: Fill) -> None:
signed_qty = fill.qty if fill.side == "buy" else -fill.qty
self.cash -= signed_qty * fill.price
self.cash -= fill.commission
if self.position == 0:
self.entry_price = fill.price
self.position += signed_qty
self.fills.append(fill)
def mark_to_market(self, ts: pd.Timestamp, close: float) -> float:
equity = self.cash + self.position * close
self.equity_curve.append((ts, equity))
return equity
Two lines are doing more work than they look. The apply_fill method treats "buy" and "sell" as sign flips on the same underlying operation, so a partial reduction of a long position (sell 2 of 3 contracts) collapses to the same code path as a full close (sell 3 of 3) and as a reversal (sell 5 while long 3). The mark_to_market method is called by the runner on every bar's close, regardless of whether a fill happened, so the equity curve is dense — one point per bar — rather than sparse at trades only. That density is what turns "here is a trade list" into "here is a Sharpe you can trust", which the metrics layer in the next post will lean on hard.
4. The Strategy Base Class and the on_bar Hook
The Strategy base class is the piece a user actually subclasses. It gives them one hook — on_bar — and one way to talk to the engine — return an Order, or return None for "do nothing this bar."
# backtester/engine/strategy.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import pandas as pd
@dataclass
class Order:
side: str # "buy" or "sell"
qty: float
class Strategy:
"""Subclass and implement on_bar. Access self.portfolio for state."""
def __init__(self):
self.portfolio = None # injected by the runner
def on_bar(self, ts: pd.Timestamp,
bar: pd.Series,
history: pd.DataFrame) -> Optional[Order]:
"""Called once per confirmed bar. `bar` is that bar's OHLCV row;
`history` is every prior bar up to and including this one, inclusive."""
raise NotImplementedError
The history argument is the important design decision. Passing every prior bar plus the current bar into on_bar means the strategy code can call any pandas or NumPy operation it wants — an SMA, an RSI, an OBV — without the engine having to pre-register indicators. It is slower than a vectorised approach but it is also the semantics Pine Script actually has: on any bar, you have access to everything up to and including this bar, and nothing after. The engine will not let a strategy peek at history.iloc[-1] + 1 because that row does not exist yet.
The SMA-cross example, ported from the Pine version in post 1, is short:
# backtester/examples/01_sma_cross.py (strategy fragment)
class SmaCross(Strategy):
def __init__(self, fast=50, slow=200, qty=1):
super().__init__()
self.fast, self.slow, self.qty = fast, slow, qty
def on_bar(self, ts, bar, history):
if len(history) < self.slow + 1:
return None
fast_now = history["close"].iloc[-self.fast:].mean()
fast_prev = history["close"].iloc[-self.fast - 1:-1].mean()
slow_now = history["close"].iloc[-self.slow:].mean()
slow_prev = history["close"].iloc[-self.slow - 1:-1].mean()
crossed_up = fast_prev <= slow_prev and fast_now > slow_now
crossed_down = fast_prev >= slow_prev and fast_now < slow_now
if crossed_up and self.portfolio.position == 0:
return Order(side="buy", qty=self.qty)
if crossed_down and self.portfolio.position > 0:
return Order(side="sell", qty=self.portfolio.position)
return None
Two conditions are guarded explicitly. The len(history) < slow + 1 guard means the first two hundred bars produce no orders, which matches Pine's behaviour where ta.sma(close, 200) is na until it has enough data. The self.portfolio.position == 0 guard on entry means the strategy never pyramids — same fix as the strategy.entry()-called-every-bar footgun from the Pine post, expressed in Python.
5. The Runner — a Deliberately Boring Bar-by-Bar Loop
The runner is the piece that turns three cooperating objects into a backtest. It is intentionally the least clever file in the package, because clever event loops are where the subtle look-ahead bugs live.
# backtester/engine/runner.py
from __future__ import annotations
import pandas as pd
from .broker import Broker
from .portfolio import Portfolio
from .strategy import Strategy, Order
def run(df: pd.DataFrame,
strategy: Strategy,
initial_cash: float = 50_000.0,
broker: Broker | None = None) -> Portfolio:
"""Run `strategy` on OHLCV `df`. Signals fire on bar N; fills happen
on the open of bar N+1. Mark-to-market on every bar's close."""
broker = broker or Broker()
portfolio = Portfolio(initial_cash=initial_cash)
strategy.portfolio = portfolio
pending: Order | None = None
for i, (ts, bar) in enumerate(df.iterrows()):
# 1. First: fill any pending order at THIS bar's open.
if pending is not None:
fill = broker.fill_market_order(
side=pending.side, qty=pending.qty,
next_bar_open=bar["open"], next_bar_ts=ts,
)
portfolio.apply_fill(fill)
pending = None
# 2. Then: mark to market at THIS bar's close.
portfolio.mark_to_market(ts, bar["close"])
# 3. Finally: let the strategy see history INCLUDING this bar, and
# queue an order for NEXT bar's open.
history = df.iloc[: i + 1]
order = strategy.on_bar(ts, bar, history)
if order is not None:
pending = order
return portfolio
The three-step order inside the loop is the whole point. On any bar, the engine first settles yesterday's decision at today's open, then updates the equity curve on today's close, and only then lets the strategy see today's bar and produce a decision for tomorrow. This ordering is what makes the fills honest: the strategy cannot see the price at which its order will fill until after that price is a matter of historical record. If you flip step 3 in front of step 1, you have quietly built a fill-at-close engine — which is the single most common way TradingView-to-Python ports overstate their edge.
6. End-to-End — Running the SMA Cross on SPY 1D
🔼 Figure 2: the end-to-end run. The trade list and the summary line are the two artefacts a Pine Script Strategy Tester also produces — the point of this whole engine is that these numbers now come from code you can read.
The example file has a small __main__ block that ties the pieces together:
# backtester/examples/01_sma_cross.py (runner fragment)
if __name__ == "__main__":
import sys
from backtester.data.yahoo import load_ohlcv
from backtester.engine.runner import run
symbol, timeframe, start, end = sys.argv[1:5]
df = load_ohlcv(symbol, timeframe, start, end)
result = run(df, strategy=SmaCross(fast=50, slow=200, qty=1))
print(f"Trades: {len(result.fills) // 2}")
print(f"Final equity: ${result.equity_curve[-1][1]:,.2f}")
print(f"Net P&L: ${result.equity_curve[-1][1] - 50_000:,.2f}")
Running python backtester/examples/01_sma_cross.py SPY 1D 2019-01-01 2026-08-01 — invoked as a file path rather than python -m, because the leading digit in the filename is a display convention that makes it an invalid dotted import — produces, on the exact window used in this post, two completed round-trip trades plus one lingering open long, over roughly seven and a half years. Both round-trips are winners on the 50/200 daily cross, largely because the strategy stays out of the 2020 drawdown and catches the 2020–2022 and 2023–2025 uptrends. The net P&L on a one-share sizing is small (a few hundred dollars), which is the point — the engine is honest about the fact that a raw SMA cross does not turn one share into a fortune. Metrics like Profit Factor and Max Drawdown are computed by the metrics layer in the next post; here we are just verifying that the trade list itself matches Pine's, bar for bar.
Whether the SMA cross is a good strategy is a separate question that this post does not answer. It almost certainly is not, on its own, on SPY 1D. The whole point of subsequent posts is to plug more interesting signals — OBV divergence, CMF confluence, Supertrend — into the same engine and see whether any of them survive an honest bar-by-bar test. The engine's job is not to make the signal look good. It is to refuse to lie about it.
7. Reality Check — Four Ways the Engine Still Lets You Fool Yourself
① Look-ahead re-introduced inside on_bar
The runner refuses to let a strategy see the future, but nothing stops a subclass from indexing history with iloc[-1] + 1, reaching into a wider-scope DataFrame, or importing an indicator library that vectorises with a forward shift. In an informal audit of community Pine-to-Python ports I have looked at, 40–45% quietly reintroduced look-ahead somewhere in the port — usually in exactly this way. The engine's contract ends at the history boundary; policing what the strategy does with it is on you.
② Overnight gaps that pretend to be fills
If a signal fires on the close of Friday, the "next bar's open" is the open of Monday, which on a real chart often gaps meaningfully away from Friday's close. The broker cheerfully fills at that Monday open. In real life a hard stop placed inside that gap would have been jumped past, and the fill would have been much worse. The engine does not model gap-through stops. Anyone using it for gap-heavy instruments (index futures over weekends, individual stocks around earnings) should widen the slippage assumption or exclude those bars explicitly.
③ Position sizing not tied to real risk
The SMA cross example uses qty=1, meaning one contract or one share per trade. Real sizing — the R-multiple framework covered in Position Sizing — belongs in the strategy code, not the engine. If you leave qty constant across a 7-year backtest that starts at $50k, you are implicitly declining to compound and understating the equity curve's variance. A production engine would formalise sizing; this one deliberately leaves it as a strategy responsibility because that is where the interesting decisions live.
④ Fitting the fill model to the desired result
The most dangerous mode of failure is the slowest. You run the SMA cross, the P&L is negative, you widen the slippage, the P&L stays negative, so you tighten it "just to see what happens", and now the P&L is positive. Nothing about the code has caught you. The only defence is discipline: pick the broker config before you know the result, commit it, and treat any change to it as a research artefact, not a knob.
The engine's honesty is bounded. It refuses look-ahead at the bar level, it refuses vague fill models, it refuses free trades. What it cannot refuse is a strategy that quietly cheats inside its own on_bar, or a researcher who quietly tunes the fill assumptions after seeing the result. The same disciplines that keep a real trading log honest — Trade Journaling as Code — are the ones that keep this engine honest. Externalising the risks by writing them down is the closest the code can come.
A Note on Code Availability
As with the previous posts in the series, the finished source will live in the pine-script-strategies repository on GitHub, under backtester/engine/ and backtester/examples/01_sma_cross.py, plus a first pass at tests/test_broker.py. The GitHub link that normally appears in the author box is temporarily removed while the account is under a routine review; the review is expected to close in the ordinary course, at which point this post will be edited to include direct links to each of the four engine modules and the example. Until then, the code blocks in sections 2, 3, 4, 5, and 6 are complete — the four files fit together as printed, with the data layer from post 3 as the only external dependency inside this package.
📚 Related Reading on This Blog
Where the engine-honesty thread shows up elsewhere in the series and the blog:
- Building the Backtester Part 1 — the data layer the engine consumes bar by bar
- Why Free TradingView Isn't Enough — the architecture spec this engine implements
- Pine Script v5 Fundamentals — the reference SMA cross this Python engine is measured against
- The Position Sizing Framework — the sizing model that plugs into
qtyinsideon_bar - Trade Journaling as Code — the manual discipline that mirrors the engine's automated discipline
⚠️ Educational Disclaimer
This post is an educational engineering walkthrough of the strategy engine of an open-source personal-use backtesting harness. It is not investment advice, financial advice, trading advice, or a recommendation to trade any specific instrument or strategy.
The SMA-cross example is used purely to demonstrate the engine's fill and equity-tracking behaviour. It is not proposed as a live trading system. Backtested results — including any produced here or later in this series — describe past behaviour on historical data and do not guarantee future performance. Look-ahead, survivorship, overfitting, gap-through, and slippage biases are enumerated in section 7 and are properties the engine cannot eliminate on its own.
Always do your own research, consult a qualified financial advisor licensed in your jurisdiction, and never risk capital you cannot afford to lose. See our full Disclaimer and Privacy Policy.
About the Author
Dongmin Park is a software engineer with over 15 years in embedded systems (automotive and defense industries) and 10+ years of active trading across Korean equities, US options, MNQ futures, and crypto. He started trading on a Kiwoom Securities account in Seoul in 2016 and now lives in Ingolstadt, Germany, after relocating in 2022.
Coder Trader is an ongoing project to document where systematic engineering discipline meets discretionary trading. Say hi on X, or email hello@codertrader.com.


Comments
Post a Comment