Build a Python Backtesting Engine
Build a Python engine that replays market bars and simulates realistic trades.
Introduction
30 Second Summary
Results can look profitable when a simulation quietly uses prices that were unavailable at the time. Once real timing catches up, the apparent edge can disappear.
In this project, you will build an event-driven market replay engine in Python that processes immutable price bars one at a time. You will compare same-close execution with next-open execution through a reproducible terminal demo.
What You'll Build
You will run one deterministic replay in your terminal to see an optimistic same-close profit shrink under next-open execution.
By the end of this project, you'll have:
- A same-close versus next-open comparison that replays the same deterministic bars through both models. You can see how fill timing changes the result.
- An auditable fill log that separates every submission timestamp from its fill timestamp. A portfolio ledger reconciles cash against positions. It exposes total fees. It calculates return. It calculates maximum drawdown. Rejections remain visible. Pending orders remain visible.
- An automated test suite that catches delayed-fill errors. Separate checks protect slippage behavior. They also protect order rejection. The final check proves the cash-plus-position accounting identity.
- Secret Mission: Add expiring limit orders that fill when a later bar crosses the limit. Orders that wait too long move to an expired list.
Are there any prerequisites?
Intermediate Python experience is required. Classes plus type hints should feel familiar. You should know basic trading terms such as bar, order, position, or fill.
Before We Start
A profitable result means little if the engine quietly trades with future information. This checkpoint commits you to trustworthy execution simulation as the project's goal.
Set Up the Replay Workspace
A trustworthy replay engine needs a known runtime before its timing rules can mean anything. Verifying Python now keeps version problems away from the execution logic.
This step checks that your Mac can run Python 3.10 or newer. A small Visual Studio Code workspace then gives each part of the engine a clear home.
In this step, get ready to:
- Verify Python 3.10 or newer in macOS Terminal.
- Open advanced-backtester as a Visual Studio Code workspace.
- Prepare the three empty Python files for the engine.
Verify your Python runtime
The engine uses syntax supported by Python 3.10 or newer. Starting the interpreter exposes the installed version in its opening banner.
- Press Cmd+Space to open Spotlight.
You will see the Spotlight search field in the middle of your screen.
- Type Terminal into the Spotlight search field.
- Press Return to open Terminal.
Terminal is ready when you can see the macOS shell prompt.
- Start the installed Python interpreter by running this command:
python3
What does this command do?
The python3 command starts the interactive interpreter. Its opening banner shows the installed Python version.
The first line of the banner determines your setup path.
✔️ I see version 3.10 or higher
Your installed runtime meets the project requirement. You can use it for every script in this project.
- Return to the macOS shell by running:
quit()
What does this command do?
The quit() call closes the interactive interpreter. You will see the macOS shell prompt again.
That runtime check is complete. Python can now run the replay engine you build next.
ⓧ I see an older version
The installed runtime is below the project minimum. The Python 3.14.7 macOS package provides a compatible runtime.
- Return to the macOS shell by running:
quit()
Why exit the interpreter?
The installer runs outside the interactive interpreter. The quit() call returns control to the macOS shell.
- Open the official Python downloads page in your browser.
- Download the Python 3.14.7 macOS package.
You will see the downloaded package in your browser's downloads list.
- Run the downloaded macOS package.
- Follow the installer until it reports that the installation is complete.
The completion screen confirms that the newer runtime is installed on your Mac.
- Close Terminal.
- Reopen Terminal through Spotlight.
- Start the updated interpreter by running:
python3
What does this check prove?
The opening banner should show Python 3.14.7. This confirms that your shell can reach the newly installed runtime.
- Return to the macOS shell by running:
quit()
What does this confirm?
The quit() call completes the interpreter check. Your updated runtime is ready for project commands.
ⓧ Command not found
Your shell cannot start Python yet. Installing the Python 3.14.7 macOS package adds the interpreter required by this project.
- Open the official Python downloads page in your browser.
- Download the Python 3.14.7 macOS package.
You will see the downloaded package in your browser's downloads list.
- Run the downloaded macOS package.
- Follow the installer until it reports that the installation is complete.
The completion screen confirms that Python is installed on your Mac.
- Close Terminal.
- Reopen Terminal through Spotlight.
- Start the installed interpreter by running:
python3
What does this check prove?
The opening banner should show Python 3.14.7. The interactive prompt underneath confirms that the interpreter is running.
- Return to the macOS shell by running:
quit()
What does this confirm?
The quit() call returns you to the macOS shell. Python is now available for the project.
Still seeing the old version?
Close every Terminal window after the installer finishes. Reopen Terminal through Spotlight before repeating the check.
If the old version still starts, help me identify which Python installation my Mac is using.
Create the workspace folder
A folder workspace keeps the editor focused on one project boundary. The advanced-backtester folder will contain the engine source file plus its demonstration and tests.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into the Spotlight search field.
Spotlight will show Visual Studio Code in the search results.
- Press Return to open Visual Studio Code.
You will see the Visual Studio Code window once the editor finishes opening.
- Click File in the top menu bar.
- Select Open Folder....
You will see the macOS folder picker.
- Select your Desktop in the folder picker.
- Click the new-folder control in the folder picker.
A name field will appear for the new folder.
- Enter advanced-backtester as the folder name.
- Press Return to create the folder.
You will see the advanced-backtester folder on your Desktop.
- Select the advanced-backtester folder.
- Click Open.
Visual Studio Code may ask whether you trust the folder. You created this empty folder yourself.
- Confirm that you trust the folder if Visual Studio Code asks.
You should see advanced-backtester at the top of the Explorer sidebar. The folder is now your workspace boundary.
Prepare the project files
The workspace uses separate files for reusable engine logic, the deterministic demonstration, and automated tests. These files stay empty until the later build steps introduce their responsibilities.
- Click the new-file control in the Explorer sidebar.
- Enter backtester.py as the file name.
You should see backtester.py directly inside advanced-backtester.
- Click the new-file control in the Explorer sidebar.
- Enter demo.py as the file name.
You should see demo.py beside backtester.py.
- Click the new-file control in the Explorer sidebar.
- Enter test_backtester.py as the file name.
You should now see all three empty files inside advanced-backtester.
- Click View in the Visual Studio Code menu bar.
- Select Terminal.
The integrated terminal opens with advanced-backtester as its starting folder.
- Confirm the project files exist by running:
ls
What does this check show?
The ls command lists the files in the current folder. You should see backtester.py, demo.py, and test_backtester.py in the output.
Before you start the interpreter, do you expect its banner to match the version you verified in macOS Terminal?
- Start Python from the integrated terminal by running:
python3
What should you see?
The opening banner should show Python 3.10 or newer. The interactive prompt confirms that Python runs from the workspace terminal.
Does the version look different?
Visual Studio Code may have been running before the Python installation finished. Close the editor before reopening the advanced-backtester workspace.
If the integrated terminal still starts a different runtime, help me compare the Python environments.
- Return to the workspace shell by running:
quit()
What does this confirm?
The quit() call returns you to the integrated shell prompt. The runtime has completed the required start-to-exit check.
Your workspace is ready. Python runs from the project folder while the three engine files wait in Explorer.
✔️ Awesome, I've got everything!
Your advanced-backtester workspace contains all three empty files. Its integrated terminal also starts Python 3.10 or newer.
ⓧ I'd like to double check the full code
Each project file should contain zero code at this point. Use the blank editor pane as the exact reference for each file.
- Select backtester.py in the Explorer sidebar.
- Confirm that the backtester.py editor pane is blank.
- Select demo.py in the Explorer sidebar.
- Confirm that the demo.py editor pane is blank.
- Select test_backtester.py in the Explorer sidebar.
- Confirm that the test_backtester.py editor pane is blank.
Your runtime and workspace are ready. Next, you will build the same-close baseline that exposes the execution-timing flaw.
Expose the Same-Close Illusion
Your Visual Studio Code workspace is ready. Now you can make deterministic bars flow through a strategy and broker.
This first version deliberately lets a signal trade at the closing price that created it. The optimistic result gives you a visible baseline for spotting the execution-timing flaw.
In this step, get ready to:
- Model immutable bars, orders, fills, and equity points.
- Build a broker with same-close execution and a stateful momentum strategy.
- Replay deterministic market data and observe an optimistic ending equity.
Model immutable replay events
An event stream represents the market as a sequence of records. Frozen dataclasses keep each record unchanged after creation.
- Switch back to backtester.py from the previous step.
- Add the imports, constants, enums, and bar record by pasting this code:
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from typing import Optional
ZERO = Decimal("0")
class Side(Enum):
BUY = "BUY"
SELL = "SELL"
class ExecutionMode(Enum):
SAME_CLOSE = "SAME_CLOSE"
NEXT_OPEN = "NEXT_OPEN"
@dataclass(frozen=True)
class Bar:
timestamp: str
symbol: str
open: Decimal
high: Decimal
low: Decimal
close: Decimal
volume: int
What does this code define?
- The Side enum limits trade direction to explicit buy or sell values.
- The ExecutionMode enum names the timing assumption used by a broker.
- The frozen Bar record stores one timestamped OHLCV observation.
- The Decimal type keeps price arithmetic exact for the values used in this engine.
- Save backtester.py.
You should see the file without red syntax underlines. The first replay event is ready.
Seeing syntax underlines?
Check that every class begins at the far left of the file. Confirm that each field inside Bar is indented by four spaces.
Ask for help with the event definitions.
- Add the remaining immutable records below Bar by pasting this code:
@dataclass(frozen=True)
class Order:
order_id: int
symbol: str
side: Side
quantity: int
submitted_at: str
@dataclass(frozen=True)
class Fill:
order_id: int
symbol: str
side: Side
quantity: int
price: Decimal
fee: Decimal
submitted_at: str
filled_at: str
@dataclass(frozen=True)
class EquityPoint:
timestamp: str
equity: Decimal
How do these records connect?
- An Order captures the strategy's request plus its submission time.
- A Fill records the executed price plus a separate fill time.
- An EquityPoint captures the portfolio value at one replay timestamp.
- Save backtester.py.
The file should still show no red syntax underlines. Your market events now have explicit shapes.
Are record names unresolved?
Keep Order below Side so Python knows the enum when it reads the type annotation. Keep the Decimal import at the top of the file.
Ask for help with the dataclass order.
Build the same-close baseline
The broker owns cash, positions, orders, fills, and the equity curve. Its same-close path executes an order against the current bar's closing price.
- Add the broker state below EquityPoint by pasting this code:
class Broker:
def __init__(
self,
label: str,
initial_cash: Decimal,
execution_mode: ExecutionMode,
) -> None:
if initial_cash <= ZERO:
raise ValueError("initial_cash must be positive")
self.label = label
self.initial_cash = initial_cash
self.cash = initial_cash
self.execution_mode = execution_mode
self.positions: dict[str, int] = {}
self.pending_orders: list[Order] = []
self.fills: list[Fill] = []
self.equity_curve: list[EquityPoint] = []
self.last_prices: dict[str, Decimal] = {}
self.current_bar: Optional[Bar] = None
self._next_order_id = 1
What does the broker own?
- The cash and positions fields form the portfolio ledger.
- The fills list preserves completed executions for later inspection.
- The equity_curve list stores the portfolio value after each bar.
- The current_bar field gives same-close execution access to the active closing price.
- Save backtester.py.
The broker class should remain free of red syntax underlines. Its initial state now has one clear owner.
Is the broker class malformed?
Check that __init__() is indented inside Broker. Confirm that every self assignment is indented inside the method.
Ask for help with the broker state.
- Add the position lookup and order creation methods inside Broker below __init__() by pasting this code:
def position(self, symbol: str) -> int:
return self.positions.get(symbol, 0)
def has_pending(self, symbol: str) -> bool:
return any(order.symbol == symbol for order in self.pending_orders)
def new_order(
self,
symbol: str,
side: Side,
quantity: int,
submitted_at: str,
) -> Order:
if quantity <= 0:
raise ValueError("quantity must be positive")
order = Order(
order_id=self._next_order_id,
symbol=symbol,
side=side,
quantity=quantity,
submitted_at=submitted_at,
)
self._next_order_id += 1
return order
How are orders created?
- The position() method returns the current quantity for one symbol.
- The has_pending() method checks whether a symbol already has a queued order.
- The new_order() method gives each order a unique identifier.
- Save backtester.py.
The new methods should align beneath __init__() with no red syntax underlines.
Did the methods leave the class?
Each def line needs four leading spaces. The method bodies need eight leading spaces.
Ask for help with method indentation.
- Add the submission method inside Broker below new_order() by pasting this code:
def submit(
self,
symbol: str,
side: Side,
quantity: int,
submitted_at: str,
) -> Order:
order = self.new_order(symbol, side, quantity, submitted_at)
if self.execution_mode is ExecutionMode.SAME_CLOSE:
if self.current_bar is None or self.current_bar.symbol != symbol:
raise RuntimeError("same-close execution requires the current symbol bar")
self.execute_order(
order,
reference_price=self.current_bar.close,
filled_at=self.current_bar.timestamp,
)
else:
self.pending_orders.append(order)
return order
Where is the timing assumption?
- The SAME_CLOSE branch uses current_bar.close as its reference price.
- The order's submission timestamp also becomes its fill timestamp in this baseline.
- The alternate branch preserves an order in pending_orders without processing it yet.
- Save backtester.py.
The submission method should sit inside Broker without syntax underlines. The same-close assumption is now explicit.
Is same-close submission failing?
Confirm that submit() is below new_order() inside the class. Check that ExecutionMode.SAME_CLOSE matches the enum spelling exactly.
Ask for help with the submission path.
- Add the execution method inside Broker below submit() by pasting this code:
def execute_order(
self,
order: Order,
reference_price: Decimal,
filled_at: str,
) -> bool:
price = reference_price
notional = price * Decimal(order.quantity)
if order.side is Side.BUY:
self.cash -= notional
self.positions[order.symbol] = self.position(order.symbol) + order.quantity
else:
self.cash += notional
self.positions[order.symbol] = self.position(order.symbol) - order.quantity
self.fills.append(
Fill(
order_id=order.order_id,
symbol=order.symbol,
side=order.side,
quantity=order.quantity,
price=price,
fee=ZERO,
submitted_at=order.submitted_at,
filled_at=filled_at,
)
)
return True
What happens during a fill?
- A buy subtracts its notional value from cash.
- A sell adds its notional value to cash.
- The position quantity moves in the direction of the trade.
- The resulting Fill preserves both timestamps for later comparison.
- Save backtester.py.
The execution method should show no red syntax underlines. The broker can now turn an order into a portfolio change.
Is fill creation underlined?
Check that every required Fill field appears in the constructor. Confirm that fee=ZERO includes the comma shown in the code.
Ask for help with fill creation.
- Add mark-to-market accounting inside Broker below execute_order() by pasting this code:
def mark_to_market(self, bar: Bar) -> Decimal:
self.last_prices[bar.symbol] = bar.close
equity = self.cash
for symbol, quantity in self.positions.items():
price = self.last_prices.get(symbol)
if price is not None:
equity += Decimal(quantity) * price
self.equity_curve.append(EquityPoint(bar.timestamp, equity))
return equity
How is equity calculated?
Mark-to-market equity starts with cash. It adds each open position valued at the latest closing price.
The resulting EquityPoint makes the portfolio path observable one bar at a time.
- Save backtester.py.
The method should remain inside Broker without syntax underlines. Your broker can now value its portfolio after each bar.
Is equity calculation failing?
Confirm that equity starts from self.cash. Check that EquityPoint receives the bar timestamp plus the calculated equity.
Ask for help with mark-to-market accounting.
The strategy needs memory because momentum depends on the relationship between consecutive closes. Its previous_close dictionary keeps that state separately for each symbol.
- Add the momentum strategy below the completed Broker class by pasting this code:
class MomentumStrategy:
def __init__(self, quantity: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.quantity = quantity
self.previous_close: dict[str, Decimal] = {}
def on_bar(self, bar: Bar, broker: Broker) -> None:
previous = self.previous_close.get(bar.symbol)
position = broker.position(bar.symbol)
if previous is not None and not broker.has_pending(bar.symbol):
if bar.close > previous and position == 0:
broker.submit(bar.symbol, Side.BUY, self.quantity, bar.timestamp)
elif bar.close < previous and position > 0:
broker.submit(bar.symbol, Side.SELL, position, bar.timestamp)
self.previous_close[bar.symbol] = bar.close
How does the strategy react?
- A rising close submits a buy when the portfolio has no position.
- A falling close submits a sell when the portfolio owns the symbol.
- The current close becomes previous_close after the decision.
- The strategy delegates execution to the broker instead of changing cash directly.
- Save backtester.py.
The strategy should appear at the far left below Broker. Your baseline now has separate decision and accounting roles.
Is the strategy outside the file structure?
Keep MomentumStrategy at the far left so it does not become part of Broker. Check the spelling of previous_close in both methods.
Ask for help with strategy state.
Run the optimistic demonstration
Deterministic bars make both the signal sequence and the final result reproducible. The next-bar gaps are present in the data even though this baseline ignores them when filling orders.
- Switch back to the empty demo.py file from the previous step.
- Add the imports plus deterministic DEMO bars by pasting this code:
from decimal import Decimal
from backtester import Bar, Broker, ExecutionMode, MomentumStrategy
D = Decimal
BARS = [
Bar("2026-01-01", "DEMO", D("100"), D("101"), D("99"), D("100"), 1000),
Bar("2026-01-02", "DEMO", D("101"), D("104"), D("100"), D("103"), 1100),
Bar("2026-01-03", "DEMO", D("110"), D("112"), D("105"), D("106"), 1300),
Bar("2026-01-04", "DEMO", D("106"), D("107"), D("104"), D("105"), 1000),
Bar("2026-01-05", "DEMO", D("100"), D("102"), D("99"), D("101"), 1200),
Bar("2026-01-06", "DEMO", D("101"), D("104"), D("100"), D("103"), 1000),
Bar("2026-01-07", "DEMO", D("112"), D("113"), D("107"), D("108"), 1400),
Bar("2026-01-08", "DEMO", D("108"), D("109"), D("106"), D("107"), 1100),
Bar("2026-01-09", "DEMO", D("102"), D("104"), D("101"), D("103"), 1200),
Bar("2026-01-10", "DEMO", D("103"), D("107"), D("102"), D("106"), 1050),
Bar("2026-01-11", "DEMO", D("114"), D("116"), D("109"), D("110"), 1500),
Bar("2026-01-12", "DEMO", D("110"), D("111"), D("108"), D("109"), 1150),
Bar("2026-01-13", "DEMO", D("104"), D("106"), D("103"), D("105"), 1250),
]
Why use deterministic bars?
The same values produce the same strategy decisions on every run. Reproducibility lets you compare execution models without market data changing underneath the experiment.
The opening gaps make execution timing matter. This baseline still chooses each fill from the signal bar's close.
- Save demo.py.
The list should contain thirteen DEMO bars without red syntax underlines. Your repeatable market path is ready.
Is the bar list underlined?
Check that every Bar call ends with a comma. Confirm that the final square bracket sits at the far left.
Ask for help with the deterministic data.
- Add the same-close replay below BARS by pasting this code:
def run_demo() -> None:
naive = Broker(
label="Naive same-close",
initial_cash=D("10000"),
execution_mode=ExecutionMode.SAME_CLOSE,
)
strategy = MomentumStrategy(quantity=10)
ending_equity = naive.initial_cash
for bar in BARS:
naive.current_bar = bar
strategy.on_bar(bar, naive)
ending_equity = naive.mark_to_market(bar)
print(f"{naive.label}: ending equity={ending_equity:.2f}")
if __name__ == "__main__":
run_demo()
What does the replay do?
- The broker starts with 10000 in cash plus same-close execution.
- Each bar becomes current before the strategy reads its closing price.
- The strategy can therefore submit an order that immediately uses that same close.
- Mark-to-market accounting records the resulting equity after every decision.
- Save demo.py.
Before you run the baseline, do you expect its final equity to sit above or below the starting value?
- Run the demonstration in the integrated terminal from the advanced-backtester workspace root by running:
python3 demo.py
You should see Naive same-close: ending equity=10090.00. The ending equity is above the starting value.
Where does the illusion come from?
The strategy sees a rising close before it submits a buy. The broker immediately gives that order the same closing price.
That fill assumes the engine can react to a completed closing value while still trading at that value. The apparent profit now gives you a result worth challenging.
Did the demonstration fail?
Confirm that the integrated terminal is still at the advanced-backtester workspace root. Check that both edited files are saved.
If the output differs, compare every value in BARS plus the strategy quantity of 10.
Ask for help with the baseline run.
✔️ Awesome, I've got everything!
Your same-close baseline runs successfully. Confirm that both Python files are saved before moving on.
ⓧ I'd like to double check the full code
Compare your complete backtester.py with this baseline:
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from typing import Optional
ZERO = Decimal("0")
class Side(Enum):
BUY = "BUY"
SELL = "SELL"
class ExecutionMode(Enum):
SAME_CLOSE = "SAME_CLOSE"
NEXT_OPEN = "NEXT_OPEN"
@dataclass(frozen=True)
class Bar:
timestamp: str
symbol: str
open: Decimal
high: Decimal
low: Decimal
close: Decimal
volume: int
@dataclass(frozen=True)
class Order:
order_id: int
symbol: str
side: Side
quantity: int
submitted_at: str
@dataclass(frozen=True)
class Fill:
order_id: int
symbol: str
side: Side
quantity: int
price: Decimal
fee: Decimal
submitted_at: str
filled_at: str
@dataclass(frozen=True)
class EquityPoint:
timestamp: str
equity: Decimal
class Broker:
def __init__(
self,
label: str,
initial_cash: Decimal,
execution_mode: ExecutionMode,
) -> None:
if initial_cash <= ZERO:
raise ValueError("initial_cash must be positive")
self.label = label
self.initial_cash = initial_cash
self.cash = initial_cash
self.execution_mode = execution_mode
self.positions: dict[str, int] = {}
self.pending_orders: list[Order] = []
self.fills: list[Fill] = []
self.equity_curve: list[EquityPoint] = []
self.last_prices: dict[str, Decimal] = {}
self.current_bar: Optional[Bar] = None
self._next_order_id = 1
def position(self, symbol: str) -> int:
return self.positions.get(symbol, 0)
def has_pending(self, symbol: str) -> bool:
return any(order.symbol == symbol for order in self.pending_orders)
def new_order(
self,
symbol: str,
side: Side,
quantity: int,
submitted_at: str,
) -> Order:
if quantity <= 0:
raise ValueError("quantity must be positive")
order = Order(
order_id=self._next_order_id,
symbol=symbol,
side=side,
quantity=quantity,
submitted_at=submitted_at,
)
self._next_order_id += 1
return order
def submit(
self,
symbol: str,
side: Side,
quantity: int,
submitted_at: str,
) -> Order:
order = self.new_order(symbol, side, quantity, submitted_at)
if self.execution_mode is ExecutionMode.SAME_CLOSE:
if self.current_bar is None or self.current_bar.symbol != symbol:
raise RuntimeError("same-close execution requires the current symbol bar")
self.execute_order(
order,
reference_price=self.current_bar.close,
filled_at=self.current_bar.timestamp,
)
else:
self.pending_orders.append(order)
return order
def execute_order(
self,
order: Order,
reference_price: Decimal,
filled_at: str,
) -> bool:
price = reference_price
notional = price * Decimal(order.quantity)
if order.side is Side.BUY:
self.cash -= notional
self.positions[order.symbol] = self.position(order.symbol) + order.quantity
else:
self.cash += notional
self.positions[order.symbol] = self.position(order.symbol) - order.quantity
self.fills.append(
Fill(
order_id=order.order_id,
symbol=order.symbol,
side=order.side,
quantity=order.quantity,
price=price,
fee=ZERO,
submitted_at=order.submitted_at,
filled_at=filled_at,
)
)
return True
def mark_to_market(self, bar: Bar) -> Decimal:
self.last_prices[bar.symbol] = bar.close
equity = self.cash
for symbol, quantity in self.positions.items():
price = self.last_prices.get(symbol)
if price is not None:
equity += Decimal(quantity) * price
self.equity_curve.append(EquityPoint(bar.timestamp, equity))
return equity
class MomentumStrategy:
def __init__(self, quantity: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.quantity = quantity
self.previous_close: dict[str, Decimal] = {}
def on_bar(self, bar: Bar, broker: Broker) -> None:
previous = self.previous_close.get(bar.symbol)
position = broker.position(bar.symbol)
if previous is not None and not broker.has_pending(bar.symbol):
if bar.close > previous and position == 0:
broker.submit(bar.symbol, Side.BUY, self.quantity, bar.timestamp)
elif bar.close < previous and position > 0:
broker.submit(bar.symbol, Side.SELL, position, bar.timestamp)
self.previous_close[bar.symbol] = bar.close
This file contains the immutable records, same-close broker path, portfolio state, and momentum strategy used by the baseline.
Compare your complete demo.py with this demonstration:
from decimal import Decimal
from backtester import Bar, Broker, ExecutionMode, MomentumStrategy
D = Decimal
BARS = [
Bar("2026-01-01", "DEMO", D("100"), D("101"), D("99"), D("100"), 1000),
Bar("2026-01-02", "DEMO", D("101"), D("104"), D("100"), D("103"), 1100),
Bar("2026-01-03", "DEMO", D("110"), D("112"), D("105"), D("106"), 1300),
Bar("2026-01-04", "DEMO", D("106"), D("107"), D("104"), D("105"), 1000),
Bar("2026-01-05", "DEMO", D("100"), D("102"), D("99"), D("101"), 1200),
Bar("2026-01-06", "DEMO", D("101"), D("104"), D("100"), D("103"), 1000),
Bar("2026-01-07", "DEMO", D("112"), D("113"), D("107"), D("108"), 1400),
Bar("2026-01-08", "DEMO", D("108"), D("109"), D("106"), D("107"), 1100),
Bar("2026-01-09", "DEMO", D("102"), D("104"), D("101"), D("103"), 1200),
Bar("2026-01-10", "DEMO", D("103"), D("107"), D("102"), D("106"), 1050),
Bar("2026-01-11", "DEMO", D("114"), D("116"), D("109"), D("110"), 1500),
Bar("2026-01-12", "DEMO", D("110"), D("111"), D("108"), D("109"), 1150),
Bar("2026-01-13", "DEMO", D("104"), D("106"), D("103"), D("105"), 1250),
]
def run_demo() -> None:
naive = Broker(
label="Naive same-close",
initial_cash=D("10000"),
execution_mode=ExecutionMode.SAME_CLOSE,
)
strategy = MomentumStrategy(quantity=10)
ending_equity = naive.initial_cash
for bar in BARS:
naive.current_bar = bar
strategy.on_bar(bar, naive)
ending_equity = naive.mark_to_market(bar)
print(f"{naive.label}: ending equity={ending_equity:.2f}")
if __name__ == "__main__":
run_demo()
This file replays the deterministic bars through the baseline. Its printed ending equity should be 10090.00.
You now have a working replay baseline with a deliberately optimistic timing assumption. Next, you will separate signal creation from execution by enforcing the market's time frontier.
Enforce the Time Frontier
Your same-close baseline produced an optimistic result by trading at the close that created each signal. That run exposed the execution-timing flaw on purpose.
An event-driven backtest needs a strict time frontier. This step adds an order queue that delays each market order until the next bar opens.
In this step, get ready to:
- Process queued market orders before the strategy receives each closing value.
- Replay every bar through broker, strategy, and portfolio phases.
- Compare submission timestamps with next-open fill timestamps.
Process queued orders at the next open
The broker must process pending orders before the strategy sees the current close. This sequence prevents a closing value from influencing an order filled at that bar's opening value.
- Switch back to backtester.py in the open Visual Studio Code workspace.
- Find the submit() method inside Broker.
- Add begin_bar() directly below submit() by pasting this code:
def begin_bar(self, bar: Bar) -> None:
self.current_bar = bar
if self.execution_mode is not ExecutionMode.NEXT_OPEN:
return
remaining: list[Order] = []
for order in self.pending_orders:
if order.symbol == bar.symbol:
self.execute_order(
order,
reference_price=bar.open,
filled_at=bar.timestamp,
)
else:
remaining.append(order)
self.pending_orders = remaining
What does this code do?
- The current_bar reference keeps same-close execution connected to the bar currently being replayed.
- The execution-mode check leaves the existing SAME_CLOSE path unchanged.
- Each matching pending order uses bar.open as its reference price.
- Orders for other symbols remain in pending_orders for a later matching bar.
- Save backtester.py.
- Switch back to the integrated terminal from earlier.
- Confirm the same-close path still runs by running this command:
python3 demo.py
What does this check prove?
The existing same-close result still runs because begin_bar() returns immediately in SAME_CLOSE mode. You should still see the optimistic ending equity from the previous step.
Seeing an error in begin_bar()?
Check that begin_bar() is indented at the same level as submit(). Confirm the method sits inside the Broker class.
Compare pending_orders with the spelling used in submit(). Help me fix my Broker.begin_bar() method.
Replay bars in event order
A replay engine owns the sequence that every bar follows. The broker receives the bar first. The strategy reacts next. Portfolio equity is recorded last.
- Add the iterable type import above the existing dataclass import in backtester.py by pasting these lines:
from collections.abc import Iterable
from dataclasses import dataclass
Why use Iterable?
The Iterable annotation allows run() to accept any source that yields bars in order. A list works today. A file-backed stream can use the same interface later.
- Add BacktestEngine below MomentumStrategy at the end of backtester.py by pasting this code:
class BacktestEngine:
def __init__(self, broker: Broker, strategy: MomentumStrategy) -> None:
self.broker = broker
self.strategy = strategy
def run(self, bars: Iterable[Bar]) -> BacktestReport:
for bar in bars:
self.broker.begin_bar(bar)
self.strategy.on_bar(bar, self.broker)
self.broker.mark_to_market(bar)
return self.broker.report()
How does the replay loop protect time?
- The call to begin_bar() processes orders that were already waiting when the bar arrived.
- The call to on_bar() lets the strategy inspect the current close after those fills finish.
- The call to mark_to_market() records portfolio equity using the completed state for that bar.
- The final call to report() converts the recorded equity curve into a backtest result.
- Save backtester.py.
- Check that the new class loads without breaking the baseline by running:
python3 demo.py
What does this run confirm?
The baseline output confirms that Python can import the new engine class. It also confirms that the existing broker behavior remains intact.
Is BacktestEngine unavailable?
Make sure BacktestEngine begins at the left edge of backtester.py. An extra indentation level can place it inside MomentumStrategy.
Confirm that Iterable is imported from collections.abc. Help me troubleshoot my BacktestEngine class.
Compare same-close with next-open execution
The same deterministic bars can now pass through two execution modes. Separate strategy instances keep each model's previous_close state independent.
- Switch back to demo.py.
- Replace the existing backtester import with this line:
from backtester import BacktestEngine, Bar, Broker, ExecutionMode, MomentumStrategy
What changed in the import?
The demo now imports BacktestEngine alongside the existing records and broker types. Both execution models can now use the same replay sequence.
- Find the existing run_demo() function.
- Replace the opening portion of run_demo() with this code:
def run_demo() -> None:
naive = Broker(
label="Naive same-close",
initial_cash=D("10000"),
execution_mode=ExecutionMode.SAME_CLOSE,
)
realistic = Broker(
label="Realistic next-open",
initial_cash=D("10000"),
execution_mode=ExecutionMode.NEXT_OPEN,
)
reports = [
BacktestEngine(naive, MomentumStrategy(quantity=10)).run(BARS),
BacktestEngine(realistic, MomentumStrategy(quantity=10)).run(BARS),
]
Why create separate brokers and strategies?
Each broker owns its own cash, positions, fills, pending orders, and equity curve. Each strategy also owns its own closing-price history.
Both runs receive the same BARS. Execution timing becomes the variable that changes the result.
- Continue run_demo() directly below the reports list by pasting this code:
print(
f"{'Model':<24}{'End equity':>14}{'Return %':>12}"
f"{'Max DD %':>12}{'Fees':>12}{'Fills':>8}{'Rejected':>10}{'Pending':>9}"
)
for report in reports:
print(
f"{report.label:<24}{report.ending_equity:>14,.2f}"
f"{report.return_pct:>12.2f}{report.max_drawdown_pct:>12.2f}"
f"{report.total_fees:>12.2f}{report.fills:>8}"
f"{report.rejected_orders:>10}{report.pending_orders:>9}"
)
print("\nRealistic fill log")
for fill in realistic.fills:
print(
f"order={fill.order_id} side={fill.side.value:<4} "
f"submitted={fill.submitted_at} filled={fill.filled_at} "
f"price={fill.price:.3f} fee={fill.fee:.3f}"
)
What will the comparison reveal?
The table places both execution models beside each other. The fill log then exposes the boundary between an order's submitted_at timestamp and its filled_at timestamp.
A delayed fill makes the time frontier visible. The strategy creates an order from one close. The broker executes it when the next matching bar begins.
- Save demo.py.
Before you run the comparison, do you expect the first realistic order to fill on its submission date or the following bar?
- Run both execution models with this command:
python3 demo.py
What does the result prove?
You should see a comparison table followed by the realistic fill log. The Naive same-close row ends above the starting equity.
The first realistic order shows submitted=2026-01-02 followed by filled=2026-01-03. The order never fills on the bar that created its signal.
Does the first order fill too early?
Check the order of calls inside BacktestEngine.run(). begin_bar() must come before on_bar().
Confirm that the realistic broker uses ExecutionMode.NEXT_OPEN. Help me debug an order that fills on its signal bar.
✔️ Awesome, I've got everything!
Your replay loop now processes queued orders before strategy signals. Double check that both files are saved.
ⓧ I'd like to double check the full code
Compare your complete backtester.py with this reference:
from collections.abc import Iterable
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from typing import Optional
BPS_DENOMINATOR = Decimal("10000")
ZERO = Decimal("0")
class Side(Enum):
BUY = "BUY"
SELL = "SELL"
class ExecutionMode(Enum):
SAME_CLOSE = "SAME_CLOSE"
NEXT_OPEN = "NEXT_OPEN"
@dataclass(frozen=True)
class Bar:
timestamp: str
symbol: str
open: Decimal
high: Decimal
low: Decimal
close: Decimal
volume: int
@dataclass(frozen=True)
class Order:
order_id: int
symbol: str
side: Side
quantity: int
submitted_at: str
@dataclass(frozen=True)
class Fill:
order_id: int
symbol: str
side: Side
quantity: int
price: Decimal
fee: Decimal
submitted_at: str
filled_at: str
@dataclass(frozen=True)
class Rejection:
order: Order
reason: str
@dataclass(frozen=True)
class EquityPoint:
timestamp: str
equity: Decimal
@dataclass(frozen=True)
class BacktestReport:
label: str
starting_equity: Decimal
ending_equity: Decimal
return_pct: Decimal
max_drawdown_pct: Decimal
total_fees: Decimal
fills: int
rejected_orders: int
pending_orders: int
class Broker:
def __init__(
self,
label: str,
initial_cash: Decimal,
execution_mode: ExecutionMode,
fee_bps: Decimal = ZERO,
slippage_bps: Decimal = ZERO,
) -> None:
if initial_cash <= ZERO:
raise ValueError("initial_cash must be positive")
if fee_bps < ZERO or slippage_bps < ZERO:
raise ValueError("fee_bps and slippage_bps cannot be negative")
self.label = label
self.initial_cash = initial_cash
self.cash = initial_cash
self.execution_mode = execution_mode
self.fee_bps = fee_bps
self.slippage_bps = slippage_bps
self.positions: dict[str, int] = {}
self.pending_orders: list[Order] = []
self.fills: list[Fill] = []
self.rejections: list[Rejection] = []
self.equity_curve: list[EquityPoint] = []
self.last_prices: dict[str, Decimal] = {}
self.current_bar: Optional[Bar] = None
self._next_order_id = 1
def position(self, symbol: str) -> int:
return self.positions.get(symbol, 0)
def has_pending(self, symbol: str) -> bool:
return any(order.symbol == symbol for order in self.pending_orders)
def new_order(
self,
symbol: str,
side: Side,
quantity: int,
submitted_at: str,
) -> Order:
if quantity <= 0:
raise ValueError("quantity must be positive")
order = Order(
order_id=self._next_order_id,
symbol=symbol,
side=side,
quantity=quantity,
submitted_at=submitted_at,
)
self._next_order_id += 1
return order
def submit(
self,
symbol: str,
side: Side,
quantity: int,
submitted_at: str,
) -> Order:
order = self.new_order(symbol, side, quantity, submitted_at)
if self.execution_mode is ExecutionMode.SAME_CLOSE:
if self.current_bar is None or self.current_bar.symbol != symbol:
raise RuntimeError("same-close execution requires the current symbol bar")
self.execute_order(
order,
reference_price=self.current_bar.close,
filled_at=self.current_bar.timestamp,
)
else:
self.pending_orders.append(order)
return order
def begin_bar(self, bar: Bar) -> None:
self.current_bar = bar
if self.execution_mode is not ExecutionMode.NEXT_OPEN:
return
remaining: list[Order] = []
for order in self.pending_orders:
if order.symbol == bar.symbol:
self.execute_order(
order,
reference_price=bar.open,
filled_at=bar.timestamp,
)
else:
remaining.append(order)
self.pending_orders = remaining
def execute_order(
self,
order: Order,
reference_price: Decimal,
filled_at: str,
apply_slippage: bool = True,
) -> bool:
if reference_price <= ZERO:
raise ValueError("reference_price must be positive")
price = (
self._slipped_price(reference_price, order.side)
if apply_slippage
else reference_price
)
notional = price * Decimal(order.quantity)
fee = notional * self.fee_bps / BPS_DENOMINATOR
if order.side is Side.BUY:
total_cost = notional + fee
if total_cost > self.cash:
self.rejections.append(Rejection(order, "insufficient cash"))
return False
self.cash -= total_cost
self.positions[order.symbol] = self.position(order.symbol) + order.quantity
else:
if order.quantity > self.position(order.symbol):
self.rejections.append(Rejection(order, "insufficient position"))
return False
self.cash += notional - fee
self.positions[order.symbol] = self.position(order.symbol) - order.quantity
self.fills.append(
Fill(
order_id=order.order_id,
symbol=order.symbol,
side=order.side,
quantity=order.quantity,
price=price,
fee=fee,
submitted_at=order.submitted_at,
filled_at=filled_at,
)
)
return True
def mark_to_market(self, bar: Bar) -> Decimal:
self.last_prices[bar.symbol] = bar.close
equity = self.cash
for symbol, quantity in self.positions.items():
price = self.last_prices.get(symbol)
if price is not None:
equity += Decimal(quantity) * price
self.equity_curve.append(EquityPoint(bar.timestamp, equity))
return equity
def report(self) -> BacktestReport:
ending_equity = (
self.equity_curve[-1].equity
if self.equity_curve
else self.initial_cash
)
return_pct = (
(ending_equity / self.initial_cash) - Decimal("1")
) * Decimal("100")
total_fees = sum((fill.fee for fill in self.fills), ZERO)
return BacktestReport(
label=self.label,
starting_equity=self.initial_cash,
ending_equity=ending_equity,
return_pct=return_pct,
max_drawdown_pct=self._max_drawdown_pct(),
total_fees=total_fees,
fills=len(self.fills),
rejected_orders=len(self.rejections),
pending_orders=len(self.pending_orders),
)
def _slipped_price(self, reference_price: Decimal, side: Side) -> Decimal:
direction = Decimal("1") if side is Side.BUY else Decimal("-1")
adjustment = direction * self.slippage_bps / BPS_DENOMINATOR
return reference_price * (Decimal("1") + adjustment)
def _max_drawdown_pct(self) -> Decimal:
if not self.equity_curve:
return ZERO
peak = self.equity_curve[0].equity
maximum = ZERO
for point in self.equity_curve:
if point.equity > peak:
peak = point.equity
if peak > ZERO:
drawdown = (peak - point.equity) / peak * Decimal("100")
if drawdown > maximum:
maximum = drawdown
return maximum
class MomentumStrategy:
def __init__(self, quantity: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.quantity = quantity
self.previous_close: dict[str, Decimal] = {}
def on_bar(self, bar: Bar, broker: Broker) -> None:
previous = self.previous_close.get(bar.symbol)
position = broker.position(bar.symbol)
if previous is not None and not broker.has_pending(bar.symbol):
if bar.close > previous and position == 0:
broker.submit(bar.symbol, Side.BUY, self.quantity, bar.timestamp)
elif bar.close < previous and position > 0:
broker.submit(bar.symbol, Side.SELL, position, bar.timestamp)
self.previous_close[bar.symbol] = bar.close
class BacktestEngine:
def __init__(self, broker: Broker, strategy: MomentumStrategy) -> None:
self.broker = broker
self.strategy = strategy
def run(self, bars: Iterable[Bar]) -> BacktestReport:
for bar in bars:
self.broker.begin_bar(bar)
self.strategy.on_bar(bar, self.broker)
self.broker.mark_to_market(bar)
return self.broker.report()
What should you compare?
Focus on the new begin_bar() method and the BacktestEngine class. Their ordering creates the strict time frontier.
Compare your complete demo.py with this reference:
from decimal import Decimal
from backtester import BacktestEngine, Bar, Broker, ExecutionMode, MomentumStrategy
D = Decimal
BARS = [
Bar("2026-01-01", "DEMO", D("100"), D("101"), D("99"), D("100"), 1000),
Bar("2026-01-02", "DEMO", D("101"), D("104"), D("100"), D("103"), 1100),
Bar("2026-01-03", "DEMO", D("110"), D("112"), D("105"), D("106"), 1300),
Bar("2026-01-04", "DEMO", D("106"), D("107"), D("104"), D("105"), 1000),
Bar("2026-01-05", "DEMO", D("100"), D("102"), D("99"), D("101"), 1200),
Bar("2026-01-06", "DEMO", D("101"), D("104"), D("100"), D("103"), 1000),
Bar("2026-01-07", "DEMO", D("112"), D("113"), D("107"), D("108"), 1400),
Bar("2026-01-08", "DEMO", D("108"), D("109"), D("106"), D("107"), 1100),
Bar("2026-01-09", "DEMO", D("102"), D("104"), D("101"), D("103"), 1200),
Bar("2026-01-10", "DEMO", D("103"), D("107"), D("102"), D("106"), 1050),
Bar("2026-01-11", "DEMO", D("114"), D("116"), D("109"), D("110"), 1500),
Bar("2026-01-12", "DEMO", D("110"), D("111"), D("108"), D("109"), 1150),
Bar("2026-01-13", "DEMO", D("104"), D("106"), D("103"), D("105"), 1250),
]
def run_demo() -> None:
naive = Broker(
label="Naive same-close",
initial_cash=D("10000"),
execution_mode=ExecutionMode.SAME_CLOSE,
)
realistic = Broker(
label="Realistic next-open",
initial_cash=D("10000"),
execution_mode=ExecutionMode.NEXT_OPEN,
)
reports = [
BacktestEngine(naive, MomentumStrategy(quantity=10)).run(BARS),
BacktestEngine(realistic, MomentumStrategy(quantity=10)).run(BARS),
]
print(
f"{'Model':<24}{'End equity':>14}{'Return %':>12}"
f"{'Max DD %':>12}{'Fees':>12}{'Fills':>8}{'Rejected':>10}{'Pending':>9}"
)
for report in reports:
print(
f"{report.label:<24}{report.ending_equity:>14,.2f}"
f"{report.return_pct:>12.2f}{report.max_drawdown_pct:>12.2f}"
f"{report.total_fees:>12.2f}{report.fills:>8}"
f"{report.rejected_orders:>10}{report.pending_orders:>9}"
)
print("\nRealistic fill log")
for fill in realistic.fills:
print(
f"order={fill.order_id} side={fill.side.value:<4} "
f"submitted={fill.submitted_at} filled={fill.filled_at} "
f"price={fill.price:.3f} fee={fill.fee:.3f}"
)
if __name__ == "__main__":
run_demo()
What should match in demo.py?
The realistic broker uses ExecutionMode.NEXT_OPEN with the same BARS as the naive broker. The final loop prints both order timestamps for every realistic fill.
That is the timing gap closed. Your strategy can create an order from a close without borrowing that close for immediate execution.
Next, you will add market friction to the delayed fills. You will also reconcile every trade against the portfolio ledger.
Make Execution and Accounting Realistic
Your replay engine now protects the time frontier. Orders created from a closing signal wait for the next bar open.
Correct timing still produces optimistic results when fills ignore slippage or fees. This step adds those costs with Decimal arithmetic. It also reconciles every fill against cash and inventory.
In this step, get ready to:
- Apply adverse slippage and fees to every filled order.
- Reject trades that exceed available cash or inventory.
- Report equity, return, fees, fills, rejections, pending orders, and maximum drawdown.
Apply execution costs
A basis point is one hundredth of a percentage point. The engine converts configured basis points into price adjustments with a denominator of 10000.
Why model both costs?
Slippage moves a buy price upward or a sell price downward. A fee reduces cash according to the filled notional.
These deterministic inputs expose how market friction changes the same strategy result.
- In backtester.py, add the basis-point denominator and reusable zero value below the imports:
BPS_DENOMINATOR = Decimal("10000")
ZERO = Decimal("0")
What do these constants do?
- BPS_DENOMINATOR converts basis points into a decimal adjustment.
- ZERO keeps zero-valued calculations inside Decimal arithmetic.
- Update the Fill record in backtester.py so each execution stores its fee:
@dataclass(frozen=True)
class Fill:
order_id: int
symbol: str
side: Side
quantity: int
price: Decimal
fee: Decimal
submitted_at: str
filled_at: str
Why store the fee on each fill?
A fill becomes the permanent record of an execution. Storing its fee lets the report sum actual charged costs.
- Update the opening of the Broker class so its constructor accepts cost settings:
class Broker:
def __init__(
self,
label: str,
initial_cash: Decimal,
execution_mode: ExecutionMode,
fee_bps: Decimal = ZERO,
slippage_bps: Decimal = ZERO,
) -> None:
if initial_cash <= ZERO:
raise ValueError("initial_cash must be positive")
if fee_bps < ZERO or slippage_bps < ZERO:
raise ValueError("fee_bps and slippage_bps cannot be negative")
self.label = label
self.initial_cash = initial_cash
self.cash = initial_cash
self.execution_mode = execution_mode
self.fee_bps = fee_bps
self.slippage_bps = slippage_bps
What changes in the broker?
- fee_bps holds the fee charged against filled notional.
- slippage_bps holds the adverse price adjustment.
- The validation prevents negative cost assumptions.
- Update the opening of execute_order() so it calculates the slipped price and fee:
def execute_order(
self,
order: Order,
reference_price: Decimal,
filled_at: str,
apply_slippage: bool = True,
) -> bool:
if reference_price <= ZERO:
raise ValueError("reference_price must be positive")
price = (
self._slipped_price(reference_price, order.side)
if apply_slippage
else reference_price
)
notional = price * Decimal(order.quantity)
fee = notional * self.fee_bps / BPS_DENOMINATOR
How are costs calculated?
- price applies side-aware slippage when the caller enables it.
- notional multiplies the execution price by the order quantity.
- fee applies the configured basis-point rate to that notional.
- Update the fill creation inside execute_order() so it records the calculated fee:
self.fills.append(
Fill(
order_id=order.order_id,
symbol=order.symbol,
side=order.side,
quantity=order.quantity,
price=price,
fee=fee,
submitted_at=order.submitted_at,
filled_at=filled_at,
)
)
return True
What enters the fill log?
Each fill now captures its adjusted execution price and fee. The submission timestamp remains separate from the fill timestamp.
- Add _slipped_price() below report() in the Broker class:
def _slipped_price(self, reference_price: Decimal, side: Side) -> Decimal:
direction = Decimal("1") if side is Side.BUY else Decimal("-1")
adjustment = direction * self.slippage_bps / BPS_DENOMINATOR
return reference_price * (Decimal("1") + adjustment)
How is slippage side-aware?
A buy uses a positive adjustment. A sell uses a negative adjustment.
Both directions move the modeled fill against the trader.
- In demo.py, update the realistic broker configuration to use the teaching cost inputs:
realistic = Broker(
label="Realistic next-open",
initial_cash=D("10000"),
execution_mode=ExecutionMode.NEXT_OPEN,
fee_bps=D("5"),
slippage_bps=D("10"),
)
What does the demo configure?
The realistic broker applies five basis points of fees. It also applies ten basis points of adverse slippage.
These values are deterministic teaching inputs for comparing execution models.
- Update the realistic fill-log loop in demo.py so it prints the execution price and fee:
print("\nRealistic fill log")
for fill in realistic.fills:
print(
f"order={fill.order_id} side={fill.side.value:<4} "
f"submitted={fill.submitted_at} filled={fill.filled_at} "
f"price={fill.price:.3f} fee={fill.fee:.3f}"
)
What does the log reveal?
The log keeps timing and cost details on one line per fill. You can now inspect when an order was submitted and how much its execution cost.
- Save backtester.py.
- Save demo.py.
- Run the cost-aware replay in the integrated terminal with this command:
python3 demo.py
What should you see?
The realistic fill log should show three-decimal execution prices. Each realistic fill should also show a nonzero fee.
Seeing a fill construction error?
Check that the Fill record contains fee: Decimal. Check that the Fill constructor passes fee=fee.
Review the cost changes with help:
Reconcile cash and positions
A fill must change the portfolio ledger as one transaction. Buys consume cash. Sells consume inventory.
The broker also needs explicit rejection records when an order asks for resources the portfolio does not have.
- Confirm the broker state section after the cost settings matches this ledger state:
self.positions: dict[str, int] = {}
self.pending_orders: list[Order] = []
self.fills: list[Fill] = []
self.rejections: list[Rejection] = []
self.equity_curve: list[EquityPoint] = []
self.last_prices: dict[str, Decimal] = {}
self.current_bar: Optional[Bar] = None
self._next_order_id = 1
What state does the broker own?
- positions maps each symbol to its current quantity.
- fills and rejections preserve completed order outcomes.
- equity_curve and last_prices support portfolio valuation.
- Replace the buy and sell accounting branch inside execute_order() with this version:
if order.side is Side.BUY:
total_cost = notional + fee
if total_cost > self.cash:
self.rejections.append(Rejection(order, "insufficient cash"))
return False
self.cash -= total_cost
self.positions[order.symbol] = self.position(order.symbol) + order.quantity
else:
if order.quantity > self.position(order.symbol):
self.rejections.append(Rejection(order, "insufficient position"))
return False
self.cash += notional - fee
self.positions[order.symbol] = self.position(order.symbol) - order.quantity
How does the ledger stay balanced?
- A buy checks the combined notional and fee before reducing cash.
- A sell checks available inventory before increasing cash.
- A rejected order leaves cash and positions unchanged.
- Replace mark_to_market() with the final equity calculation:
def mark_to_market(self, bar: Bar) -> Decimal:
self.last_prices[bar.symbol] = bar.close
equity = self.cash
for symbol, quantity in self.positions.items():
price = self.last_prices.get(symbol)
if price is not None:
equity += Decimal(quantity) * price
self.equity_curve.append(EquityPoint(bar.timestamp, equity))
return equity
What is mark-to-market equity?
The method starts with available cash. It adds each position valued at its latest closing price.
The resulting equity point captures the portfolio value at that bar.
- Save backtester.py.
- Run the replay again to exercise the updated ledger:
python3 demo.py
What does this check prove?
The replay should complete without a traceback. The realistic fill log should still contain six completed fills.
Seeing an insufficient cash rejection?
Check that the demo starts each broker with D("10000"). Check that the momentum strategy submits a quantity of 10.
Trace the rejected order with help:
Build the performance report
A useful backtest report connects execution records to portfolio outcomes. It also measures maximum drawdown from each equity peak to the lowest later point.
- Update the BacktestReport record so it carries every comparison metric:
@dataclass(frozen=True)
class BacktestReport:
label: str
starting_equity: Decimal
ending_equity: Decimal
return_pct: Decimal
max_drawdown_pct: Decimal
total_fees: Decimal
fills: int
rejected_orders: int
pending_orders: int
What belongs in the report?
The report combines performance metrics with execution counts. This makes optimistic results easier to challenge.
- Replace report() with the final aggregation logic:
def report(self) -> BacktestReport:
ending_equity = (
self.equity_curve[-1].equity
if self.equity_curve
else self.initial_cash
)
return_pct = (
(ending_equity / self.initial_cash) - Decimal("1")
) * Decimal("100")
total_fees = sum((fill.fee for fill in self.fills), ZERO)
return BacktestReport(
label=self.label,
starting_equity=self.initial_cash,
ending_equity=ending_equity,
return_pct=return_pct,
max_drawdown_pct=self._max_drawdown_pct(),
total_fees=total_fees,
fills=len(self.fills),
rejected_orders=len(self.rejections),
pending_orders=len(self.pending_orders),
)
How is the report assembled?
- Ending equity comes from the final equity point.
- Return compares ending equity with initial cash.
- Fees and order counts come from the broker's event records.
- Add _max_drawdown_pct() below _slipped_price() in the Broker class:
def _max_drawdown_pct(self) -> Decimal:
if not self.equity_curve:
return ZERO
peak = self.equity_curve[0].equity
maximum = ZERO
for point in self.equity_curve:
if point.equity > peak:
peak = point.equity
if peak > ZERO:
drawdown = (peak - point.equity) / peak * Decimal("100")
if drawdown > maximum:
maximum = drawdown
return maximum
How is drawdown measured?
The method carries the highest equity seen so far as the peak. Each later equity point is measured below that peak.
The largest percentage decline becomes the maximum drawdown.
- Update the report-table output in demo.py so it prints every metric:
print(
f"{'Model':<24}{'End equity':>14}{'Return %':>12}"
f"{'Max DD %':>12}{'Fees':>12}{'Fills':>8}{'Rejected':>10}{'Pending':>9}"
)
for report in reports:
print(
f"{report.label:<24}{report.ending_equity:>14,.2f}"
f"{report.return_pct:>12.2f}{report.max_drawdown_pct:>12.2f}"
f"{report.total_fees:>12.2f}{report.fills:>8}"
f"{report.rejected_orders:>10}{report.pending_orders:>9}"
)
Why compare the models side by side?
Both brokers replay the same deterministic bars. The table isolates the effect of execution timing and costs.
- Save backtester.py.
- Save demo.py.
Before you run the final replay, which model do you expect to finish with lower equity once timing, slippage, and fees all apply?
- Run the completed comparison in the integrated terminal with this command:
python3 demo.py
What should the final replay show?
The realistic row should show nonzero fees and lower ending equity than the same-close row. It should report six fills.
The realistic row should also report zero rejections and zero pending orders.
Do the report values look wrong?
Check that only the realistic broker receives fee_bps=D("5") and slippage_bps=D("10"). Check that both brokers replay the same BARS list.
Compare the two report paths with help:
That is the accounting core working. Your broker now exposes the cost of realistic execution instead of hiding it inside a plausible profit figure.
✔️ Awesome, I've got everything!
Great. Confirm that backtester.py is saved with the complete execution and portfolio-accounting implementation.
ⓧ I'd like to double check the full code
Compare your complete backtester.py file with this reference:
from collections.abc import Iterable
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from typing import Optional
BPS_DENOMINATOR = Decimal("10000")
ZERO = Decimal("0")
class Side(Enum):
BUY = "BUY"
SELL = "SELL"
class ExecutionMode(Enum):
SAME_CLOSE = "SAME_CLOSE"
NEXT_OPEN = "NEXT_OPEN"
@dataclass(frozen=True)
class Bar:
timestamp: str
symbol: str
open: Decimal
high: Decimal
low: Decimal
close: Decimal
volume: int
@dataclass(frozen=True)
class Order:
order_id: int
symbol: str
side: Side
quantity: int
submitted_at: str
@dataclass(frozen=True)
class Fill:
order_id: int
symbol: str
side: Side
quantity: int
price: Decimal
fee: Decimal
submitted_at: str
filled_at: str
@dataclass(frozen=True)
class Rejection:
order: Order
reason: str
@dataclass(frozen=True)
class EquityPoint:
timestamp: str
equity: Decimal
@dataclass(frozen=True)
class BacktestReport:
label: str
starting_equity: Decimal
ending_equity: Decimal
return_pct: Decimal
max_drawdown_pct: Decimal
total_fees: Decimal
fills: int
rejected_orders: int
pending_orders: int
class Broker:
def __init__(
self,
label: str,
initial_cash: Decimal,
execution_mode: ExecutionMode,
fee_bps: Decimal = ZERO,
slippage_bps: Decimal = ZERO,
) -> None:
if initial_cash <= ZERO:
raise ValueError("initial_cash must be positive")
if fee_bps < ZERO or slippage_bps < ZERO:
raise ValueError("fee_bps and slippage_bps cannot be negative")
self.label = label
self.initial_cash = initial_cash
self.cash = initial_cash
self.execution_mode = execution_mode
self.fee_bps = fee_bps
self.slippage_bps = slippage_bps
self.positions: dict[str, int] = {}
self.pending_orders: list[Order] = []
self.fills: list[Fill] = []
self.rejections: list[Rejection] = []
self.equity_curve: list[EquityPoint] = []
self.last_prices: dict[str, Decimal] = {}
self.current_bar: Optional[Bar] = None
self._next_order_id = 1
def position(self, symbol: str) -> int:
return self.positions.get(symbol, 0)
def has_pending(self, symbol: str) -> bool:
return any(order.symbol == symbol for order in self.pending_orders)
def new_order(
self,
symbol: str,
side: Side,
quantity: int,
submitted_at: str,
) -> Order:
if quantity <= 0:
raise ValueError("quantity must be positive")
order = Order(
order_id=self._next_order_id,
symbol=symbol,
side=side,
quantity=quantity,
submitted_at=submitted_at,
)
self._next_order_id += 1
return order
def submit(
self,
symbol: str,
side: Side,
quantity: int,
submitted_at: str,
) -> Order:
order = self.new_order(symbol, side, quantity, submitted_at)
if self.execution_mode is ExecutionMode.SAME_CLOSE:
if self.current_bar is None or self.current_bar.symbol != symbol:
raise RuntimeError("same-close execution requires the current symbol bar")
self.execute_order(
order,
reference_price=self.current_bar.close,
filled_at=self.current_bar.timestamp,
)
else:
self.pending_orders.append(order)
return order
def begin_bar(self, bar: Bar) -> None:
self.current_bar = bar
if self.execution_mode is not ExecutionMode.NEXT_OPEN:
return
remaining: list[Order] = []
for order in self.pending_orders:
if order.symbol == bar.symbol:
self.execute_order(
order,
reference_price=bar.open,
filled_at=bar.timestamp,
)
else:
remaining.append(order)
self.pending_orders = remaining
def execute_order(
self,
order: Order,
reference_price: Decimal,
filled_at: str,
apply_slippage: bool = True,
) -> bool:
if reference_price <= ZERO:
raise ValueError("reference_price must be positive")
price = (
self._slipped_price(reference_price, order.side)
if apply_slippage
else reference_price
)
notional = price * Decimal(order.quantity)
fee = notional * self.fee_bps / BPS_DENOMINATOR
if order.side is Side.BUY:
total_cost = notional + fee
if total_cost > self.cash:
self.rejections.append(Rejection(order, "insufficient cash"))
return False
self.cash -= total_cost
self.positions[order.symbol] = self.position(order.symbol) + order.quantity
else:
if order.quantity > self.position(order.symbol):
self.rejections.append(Rejection(order, "insufficient position"))
return False
self.cash += notional - fee
self.positions[order.symbol] = self.position(order.symbol) - order.quantity
self.fills.append(
Fill(
order_id=order.order_id,
symbol=order.symbol,
side=order.side,
quantity=order.quantity,
price=price,
fee=fee,
submitted_at=order.submitted_at,
filled_at=filled_at,
)
)
return True
def mark_to_market(self, bar: Bar) -> Decimal:
self.last_prices[bar.symbol] = bar.close
equity = self.cash
for symbol, quantity in self.positions.items():
price = self.last_prices.get(symbol)
if price is not None:
equity += Decimal(quantity) * price
self.equity_curve.append(EquityPoint(bar.timestamp, equity))
return equity
def report(self) -> BacktestReport:
ending_equity = (
self.equity_curve[-1].equity
if self.equity_curve
else self.initial_cash
)
return_pct = (
(ending_equity / self.initial_cash) - Decimal("1")
) * Decimal("100")
total_fees = sum((fill.fee for fill in self.fills), ZERO)
return BacktestReport(
label=self.label,
starting_equity=self.initial_cash,
ending_equity=ending_equity,
return_pct=return_pct,
max_drawdown_pct=self._max_drawdown_pct(),
total_fees=total_fees,
fills=len(self.fills),
rejected_orders=len(self.rejections),
pending_orders=len(self.pending_orders),
)
def _slipped_price(self, reference_price: Decimal, side: Side) -> Decimal:
direction = Decimal("1") if side is Side.BUY else Decimal("-1")
adjustment = direction * self.slippage_bps / BPS_DENOMINATOR
return reference_price * (Decimal("1") + adjustment)
def _max_drawdown_pct(self) -> Decimal:
if not self.equity_curve:
return ZERO
peak = self.equity_curve[0].equity
maximum = ZERO
for point in self.equity_curve:
if point.equity > peak:
peak = point.equity
if peak > ZERO:
drawdown = (peak - point.equity) / peak * Decimal("100")
if drawdown > maximum:
maximum = drawdown
return maximum
class MomentumStrategy:
def __init__(self, quantity: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.quantity = quantity
self.previous_close: dict[str, Decimal] = {}
def on_bar(self, bar: Bar, broker: Broker) -> None:
previous = self.previous_close.get(bar.symbol)
position = broker.position(bar.symbol)
if previous is not None and not broker.has_pending(bar.symbol):
if bar.close > previous and position == 0:
broker.submit(bar.symbol, Side.BUY, self.quantity, bar.timestamp)
elif bar.close < previous and position > 0:
broker.submit(bar.symbol, Side.SELL, position, bar.timestamp)
self.previous_close[bar.symbol] = bar.close
class BacktestEngine:
def __init__(self, broker: Broker, strategy: MomentumStrategy) -> None:
self.broker = broker
self.strategy = strategy
def run(self, bars: Iterable[Bar]) -> BacktestReport:
for bar in bars:
self.broker.begin_bar(bar)
self.strategy.on_bar(bar, self.broker)
self.broker.mark_to_market(bar)
return self.broker.report()
How should you compare this file?
Check the class and method order first. Compare each cost calculation and accounting update after the structure matches.
✔️ Awesome, I've got everything!
Confirm that demo.py is saved with both execution models and the complete comparison output.
ⓧ I'd like to double check the full code
Compare your complete demo.py file with this reference:
from decimal import Decimal
from backtester import BacktestEngine, Bar, Broker, ExecutionMode, MomentumStrategy
D = Decimal
BARS = [
Bar("2026-01-01", "DEMO", D("100"), D("101"), D("99"), D("100"), 1000),
Bar("2026-01-02", "DEMO", D("101"), D("104"), D("100"), D("103"), 1100),
Bar("2026-01-03", "DEMO", D("110"), D("112"), D("105"), D("106"), 1300),
Bar("2026-01-04", "DEMO", D("106"), D("107"), D("104"), D("105"), 1000),
Bar("2026-01-05", "DEMO", D("100"), D("102"), D("99"), D("101"), 1200),
Bar("2026-01-06", "DEMO", D("101"), D("104"), D("100"), D("103"), 1000),
Bar("2026-01-07", "DEMO", D("112"), D("113"), D("107"), D("108"), 1400),
Bar("2026-01-08", "DEMO", D("108"), D("109"), D("106"), D("107"), 1100),
Bar("2026-01-09", "DEMO", D("102"), D("104"), D("101"), D("103"), 1200),
Bar("2026-01-10", "DEMO", D("103"), D("107"), D("102"), D("106"), 1050),
Bar("2026-01-11", "DEMO", D("114"), D("116"), D("109"), D("110"), 1500),
Bar("2026-01-12", "DEMO", D("110"), D("111"), D("108"), D("109"), 1150),
Bar("2026-01-13", "DEMO", D("104"), D("106"), D("103"), D("105"), 1250),
]
def run_demo() -> None:
naive = Broker(
label="Naive same-close",
initial_cash=D("10000"),
execution_mode=ExecutionMode.SAME_CLOSE,
)
realistic = Broker(
label="Realistic next-open",
initial_cash=D("10000"),
execution_mode=ExecutionMode.NEXT_OPEN,
fee_bps=D("5"),
slippage_bps=D("10"),
)
reports = [
BacktestEngine(naive, MomentumStrategy(quantity=10)).run(BARS),
BacktestEngine(realistic, MomentumStrategy(quantity=10)).run(BARS),
]
print(
f"{'Model':<24}{'End equity':>14}{'Return %':>12}"
f"{'Max DD %':>12}{'Fees':>12}{'Fills':>8}{'Rejected':>10}{'Pending':>9}"
)
for report in reports:
print(
f"{report.label:<24}{report.ending_equity:>14,.2f}"
f"{report.return_pct:>12.2f}{report.max_drawdown_pct:>12.2f}"
f"{report.total_fees:>12.2f}{report.fills:>8}"
f"{report.rejected_orders:>10}{report.pending_orders:>9}"
)
print("\nRealistic fill log")
for fill in realistic.fills:
print(
f"order={fill.order_id} side={fill.side.value:<4} "
f"submitted={fill.submitted_at} filled={fill.filled_at} "
f"price={fill.price:.3f} fee={fill.fee:.3f}"
)
if __name__ == "__main__":
run_demo()
What should remain unchanged?
The deterministic BARS sequence stays the same. Only the realistic broker's costs and the richer output distinguish this version.
Your engine now measures execution friction and reconciles portfolio state. Next up, you will protect these mechanics with automated invariant tests.
Prove the Engine's Invariants
Your replay engine now produces a realistic ledger. Plausible output alone cannot prove the implementation is trustworthy.
Python's unittest framework turns each broker rule into an executable invariant. A regression now points to the behavior that changed.
Four focused tests will lock down the broker contract.
In this step, get ready to:
- Create deterministic bars for isolated broker tests.
- Prove fill timing across both execution modes.
- Protect inventory rejection plus exact portfolio accounting.
Prove delayed next-open fills
A deterministic bar fixture gives every test controlled prices. This keeps each failure tied to broker behavior.
- Return to the empty test_backtester.py file in the Visual Studio Code Explorer sidebar.
- Add the reusable test setup by pasting this code:
import unittest
from decimal import Decimal
from backtester import Bar, Broker, ExecutionMode, Side
D = Decimal
def make_bar(timestamp: str, open_price: str, close_price: str) -> Bar:
open_value = D(open_price)
close_value = D(close_price)
return Bar(
timestamp=timestamp,
symbol="DEMO",
open=open_value,
high=max(open_value, close_value) + D("1"),
low=min(open_value, close_value) - D("1"),
close=close_value,
volume=1000,
)
What Does This Setup Do?
- The imports give the tests access to the broker types plus Python's assertion tools.
- The D alias keeps every test price in exact decimal form.
- The make_bar() fixture builds deterministic bars from an open price plus a close price.
- The calculated high plus low keep every generated bar internally consistent.
- Save test_backtester.py.
- Confirm the editor now shows make_bar() beneath D.
Can't Find the Fixture?
Check that the code was added to test_backtester.py. Make sure make_bar() starts at the left edge of the editor.
Ask for help checking the fixture structure.
The delayed-fill invariant checks the time frontier. Adverse slippage also has a precise expected price.
- Append the first broker test below make_bar() by pasting this code:
class BrokerTests(unittest.TestCase):
def test_next_open_order_waits_and_slips_adversely(self) -> None:
broker = Broker(
"next-open test",
D("1000"),
ExecutionMode.NEXT_OPEN,
fee_bps=D("0"),
slippage_bps=D("10"),
)
first = make_bar("2026-01-01", "100", "101")
second = make_bar("2026-01-02", "110", "109")
broker.begin_bar(first)
broker.submit("DEMO", Side.BUY, 1, first.timestamp)
broker.mark_to_market(first)
self.assertEqual(len(broker.fills), 0)
self.assertEqual(len(broker.pending_orders), 1)
broker.begin_bar(second)
self.assertEqual(len(broker.fills), 1)
self.assertEqual(broker.fills[0].filled_at, "2026-01-02")
self.assertEqual(broker.fills[0].price, D("110.110"))
if __name__ == "__main__":
unittest.main(verbosity=2)
What Does This Test Prove?
- The first bar submits one buy order in NEXT_OPEN mode.
- The first assertions prove that the submission bar creates no fill.
- The second bar gives the queued order its next opening price.
- The final assertions confirm a fill price of 110.110 after adverse buy-side slippage.
- Save test_backtester.py.
Before you run the first invariant, do you expect the order to fill on its submission bar?
- Run the first test from the integrated terminal by running this command:
python3 test_backtester.py
What Does This Command Check?
Python executes test_backtester.py as the main script. The test runner discovers the method inside BrokerTests.
The runner completes one test with OK. Your next-open queue now has an executable timing guard.
First Test Not Passing?
Check the indentation beneath BrokerTests. Confirm that unittest.main(verbosity=2) remains below the class.
Ask for help comparing the delayed-fill assertions.
Test immediate fills and rejection
Same-close execution promises an immediate fill at the current close.
An unsupported sell must enter the rejection ledger. These outcomes protect separate broker branches.
- Place the next method inside BrokerTests immediately above if __name__ == "__main__":.
- Add the same-close test by pasting this code:
def test_same_close_order_fills_immediately(self) -> None:
broker = Broker("same-close test", D("1000"), ExecutionMode.SAME_CLOSE)
current = make_bar("2026-01-01", "100", "102")
broker.begin_bar(current)
broker.submit("DEMO", Side.BUY, 1, current.timestamp)
self.assertEqual(len(broker.fills), 1)
self.assertEqual(broker.fills[0].price, D("102"))
self.assertEqual(broker.fills[0].filled_at, current.timestamp)
What Does the Immediate-Fill Test Prove?
- The broker receives a current bar with a closing price of 102.
- The buy submission executes during that same bar.
- The assertions pin the fill count plus fill timestamp to the same-close contract.
- Save test_backtester.py.
- Run the expanded test file by running this command:
python3 test_backtester.py
What Does This Run Confirm?
The same command reruns every test method currently inside BrokerTests. This catches interactions between the two execution-mode checks.
Two tests now finish with OK. Both execution modes have explicit timing protection.
Only One Test Running?
Confirm the new method remains indented inside BrokerTests. Its name must begin with test_ so the runner discovers it.
Ask for help checking test discovery.
- Place the rejection test inside BrokerTests immediately above if __name__ == "__main__":.
- Add the insufficient-position scenario by pasting this code:
def test_sell_without_inventory_is_rejected(self) -> None:
broker = Broker("rejection test", D("1000"), ExecutionMode.NEXT_OPEN)
first = make_bar("2026-01-01", "100", "101")
second = make_bar("2026-01-02", "99", "100")
broker.begin_bar(first)
broker.submit("DEMO", Side.SELL, 1, first.timestamp)
broker.begin_bar(second)
self.assertEqual(len(broker.fills), 0)
self.assertEqual(len(broker.rejections), 1)
self.assertEqual(broker.rejections[0].reason, "insufficient position")
What Does the Rejection Test Prove?
- The broker queues a sell while its DEMO position is zero.
- The next bar gives the broker an execution opportunity.
- The assertions prove that no fill is created.
- The rejection ledger records insufficient position as the reason.
- Save test_backtester.py.
- Run all current tests by running this command:
python3 test_backtester.py
What Does This Run Protect?
The test runner now checks both fill paths plus the rejection path. A change that accidentally permits unsupported sells becomes visible immediately.
Three tests now finish with OK. Your inventory boundary is protected alongside execution timing.
Rejection Test Failing?
Confirm the test submits Side.SELL before any buy occurs. Check that the second bar reaches begin_bar() so the queued order is processed.
Ask for help tracing the rejection path.
Prove the accounting identity
A round trip links trade execution to portfolio accounting. With zero costs, the price movement flows directly into cash.
- Place the final method inside BrokerTests immediately above if __name__ == "__main__":.
- Add the zero-cost round-trip test by pasting this code:
def test_zero_cost_round_trip_preserves_accounting_identity(self) -> None:
broker = Broker("accounting test", D("1000"), ExecutionMode.NEXT_OPEN)
first = make_bar("2026-01-01", "95", "96")
second = make_bar("2026-01-02", "100", "105")
third = make_bar("2026-01-03", "110", "110")
broker.begin_bar(first)
broker.submit("DEMO", Side.BUY, 2, first.timestamp)
broker.mark_to_market(first)
broker.begin_bar(second)
broker.mark_to_market(second)
broker.submit("DEMO", Side.SELL, 2, second.timestamp)
broker.begin_bar(third)
broker.mark_to_market(third)
self.assertEqual(broker.position("DEMO"), 0)
self.assertEqual(broker.cash, D("1020"))
self.assertEqual(broker.report().ending_equity, D("1020"))
What Does the Round Trip Prove?
- The first queued order buys two units at the second bar's open of 100.
- The second queued order sells two units at the third bar's open of 110.
- The position returns to zero after the completed round trip.
- The 20 gain raises both cash plus ending equity from 1000 to 1020.
- Save test_backtester.py.
Before you run the complete suite, do you expect every timing plus accounting rule to hold together?
- Run the complete invariant suite by running this command:
python3 test_backtester.py
What Does the Complete Suite Check?
Python runs every method whose name begins with test_ inside BrokerTests. The suite checks the broker through public state plus report output.
All four tests finish with OK. The engine now defends its own contract.
Suite Not Finishing With OK?
Use the failing method name to isolate the broken invariant. Compare that method with the matching code in the full-file reference below.
Ask for help interpreting the failed assertion.
✔️ Awesome, I've got everything!
Your saved test_backtester.py file now runs four invariant tests. Each test finishes successfully.
ⓧ I'd like to double check the full code
Compare your complete test_backtester.py file with this reference:
import unittest
from decimal import Decimal
from backtester import Bar, Broker, ExecutionMode, Side
D = Decimal
def make_bar(timestamp: str, open_price: str, close_price: str) -> Bar:
open_value = D(open_price)
close_value = D(close_price)
return Bar(
timestamp=timestamp,
symbol="DEMO",
open=open_value,
high=max(open_value, close_value) + D("1"),
low=min(open_value, close_value) - D("1"),
close=close_value,
volume=1000,
)
class BrokerTests(unittest.TestCase):
def test_next_open_order_waits_and_slips_adversely(self) -> None:
broker = Broker(
"next-open test",
D("1000"),
ExecutionMode.NEXT_OPEN,
fee_bps=D("0"),
slippage_bps=D("10"),
)
first = make_bar("2026-01-01", "100", "101")
second = make_bar("2026-01-02", "110", "109")
broker.begin_bar(first)
broker.submit("DEMO", Side.BUY, 1, first.timestamp)
broker.mark_to_market(first)
self.assertEqual(len(broker.fills), 0)
self.assertEqual(len(broker.pending_orders), 1)
broker.begin_bar(second)
self.assertEqual(len(broker.fills), 1)
self.assertEqual(broker.fills[0].filled_at, "2026-01-02")
self.assertEqual(broker.fills[0].price, D("110.110"))
def test_same_close_order_fills_immediately(self) -> None:
broker = Broker("same-close test", D("1000"), ExecutionMode.SAME_CLOSE)
current = make_bar("2026-01-01", "100", "102")
broker.begin_bar(current)
broker.submit("DEMO", Side.BUY, 1, current.timestamp)
self.assertEqual(len(broker.fills), 1)
self.assertEqual(broker.fills[0].price, D("102"))
self.assertEqual(broker.fills[0].filled_at, current.timestamp)
def test_sell_without_inventory_is_rejected(self) -> None:
broker = Broker("rejection test", D("1000"), ExecutionMode.NEXT_OPEN)
first = make_bar("2026-01-01", "100", "101")
second = make_bar("2026-01-02", "99", "100")
broker.begin_bar(first)
broker.submit("DEMO", Side.SELL, 1, first.timestamp)
broker.begin_bar(second)
self.assertEqual(len(broker.fills), 0)
self.assertEqual(len(broker.rejections), 1)
self.assertEqual(broker.rejections[0].reason, "insufficient position")
def test_zero_cost_round_trip_preserves_accounting_identity(self) -> None:
broker = Broker("accounting test", D("1000"), ExecutionMode.NEXT_OPEN)
first = make_bar("2026-01-01", "95", "96")
second = make_bar("2026-01-02", "100", "105")
third = make_bar("2026-01-03", "110", "110")
broker.begin_bar(first)
broker.submit("DEMO", Side.BUY, 2, first.timestamp)
broker.mark_to_market(first)
broker.begin_bar(second)
broker.mark_to_market(second)
broker.submit("DEMO", Side.SELL, 2, second.timestamp)
broker.begin_bar(third)
broker.mark_to_market(third)
self.assertEqual(broker.position("DEMO"), 0)
self.assertEqual(broker.cash, D("1020"))
self.assertEqual(broker.report().ending_equity, D("1020"))
if __name__ == "__main__":
unittest.main(verbosity=2)
Secret mission
Add Expiring Limit Orders
Market orders accept the next modeled price. Limit orders add a price constraint plus a deadline. Extend your engine so one order fills when a later bar crosses its limit while another expires untouched.
Clean Up Your Resources
Clean Up Your Resources
Your backtester lives entirely on your Mac, so there are no ongoing service costs. Decide whether to keep the workspace ready, close it for now, or delete it entirely.
Resources You Used:
- The advanced-backtester folder containing backtester.py, demo.py, test_backtester.py, limit_orders.py, and limit_demo.py.
Keep everything running
No action is needed. Choose this option if you want to keep experimenting with execution timing, trading costs, or limit-order rules.
- Keep the advanced-backtester folder on your Mac.
- Return to the workspace whenever you want to compare same-close execution with next-open execution.
- Preserve the tested files as a baseline before adding more order types.
Pause - I'll come back to this later
Each script exits after printing its output or completing its tests. Pausing means closing your editor while preserving the complete workspace.
- Wait for any command in the integrated terminal to finish.
- Leave the advanced-backtester folder unchanged.
- Close Visual Studio Code when you are finished.
Delete - I don't want to use this again
Remove the local workspace if you do not plan to revisit the engine. Deleting this folder is permanent.
This removes only the project files. The project created no accounts, cloud resources, or paid services.
- Switch back to the integrated terminal from earlier.
- Move out of the advanced-backtester folder by running this command:
cd ..
What Does This Command Do?
This command moves the shell into the folder that contains your workspace. The shell must sit outside the workspace before removing it.
- Delete the complete advanced-backtester folder by running this command:
rm -rf advanced-backtester
What Does This Command Do?
This command recursively removes the named folder without asking for confirmation. Every project file inside that folder is removed with it.
- Confirm the workspace is gone by listing the contents of the parent folder:
ls
What Should You See?
You should see the remaining items in the parent folder. The advanced-backtester folder should be absent from the list.
Still See the Workspace?
Check that your terminal moved into the folder containing advanced-backtester before you ran the deletion command. Confirm that the folder name uses the same spelling.
Help me remove my local backtesting workspace.
Nice Work!
Nice Work!
You did it! Your advanced-backtester workspace is now a tested event-driven backtesting engine that exposes unrealistic execution assumptions through reproducible results.
You've learned how to:
- Built an asset-agnostic replay loop with explicit state transitions for every market bar.
- Exposed the optimistic same-close flaw. Enforced the time frontier with next-open queuing plus distinct order submission timestamps plus fill timestamps.
- Added realistic execution and accounting through configurable fees plus adverse slippage. Reconciled cash with positions using exact decimal arithmetic. Protected fill timing through four automated invariant tests. The tests also cover rejection behavior plus the accounting identity.
- Secret Mission: Extended the engine with expiring limit orders. Crossing rules fill eligible orders at a constrained price. Lifetime rules move untouched tickets into the expired-order list.
Ready to quiz yourself?