Installation
GUI Desktop
Reamer is distributed as a self-contained desktop application. No Python installation, no terminal, no configuration required.
| Platform | Distribution | How to run |
|---|---|---|
| Windows | .exe installer | Run the installer, then launch Reamer from the Start menu |
| macOS | .dmg | Open the DMG, drag Reamer to Applications, double-click to open |
| Linux | .AppImage | chmod +x Reamer.AppImage && ./Reamer.AppImage |
reamer_py (headless / scripts / notebooks)
Requires Python 3.10 or later. reamer_py is distributed as a pre-built wheel — download the .whl matching your OS and Python version from GitHub Releases, then install it:
pip install reamer_py-VERSION-PLATFORM.whl
Verify the install:
python -c "import reamer_py; print('ok')"
reamer_py works in Python scripts, Jupyter notebooks, and any Python 3.10+ environment. The order helpers are imported from the bundled engine.orders sub-package:
from engine.orders import buy_market, sell_market, close_position, buy_limit, sell_limit
Getting started
Path A — GUI Desktop
- Open Reamer. Click the Data tab. Click Add CSV and select your OHLCV file. The smart parser auto-detects delimiters, column names, and timestamp format — most files from Yahoo Finance, Alpaca, Databento, Dukascopy, IBKR, and MT4/MT5 load without any preprocessing.
- Click the Strategy Lab tab. Paste or type your strategy class in the editor. Execution parameters (capital, leverage, slippage, spread, commission) are in the Settings Panel on the right.
- Click Run. Results appear in the Backtest Results tab. Step through the run bar-by-bar in the Backtest Replay tab.
print()output from your strategy appears in the Console sub-tab during replay.
Minimal working strategy — paste this into Strategy Lab:
from engine.orders import buy_market, close_position
class MyFirstStrategy:
def __init__(self, config_path=None):
self.in_position = False
self.bar = 0
def on_candle(self, data):
self.bar += 1
ticker = data.tickers[0] # uppercase: "SPX", "BTCUSD", …
tv = data[ticker]
if not tv.valid:
return None
if self.bar == 5 and not self.in_position:
self.in_position = True
return buy_market(10.0, ticker=ticker)
if self.bar == 15 and self.in_position:
self.in_position = False
return close_position(ticker=ticker)
return None
Path B — reamer_py (headless)
- Install: download the
.whlfor your platform from GitHub Releases and runpip install reamer_py-VERSION-PLATFORM.whl - Build bars, configure execution, and run:
import reamer_py
from engine.orders import buy_market, close_position
class MyFirstStrategy:
def __init__(self, config_path=None):
self.in_position = False
self.bar = 0
def on_candle(self, data):
self.bar += 1
ticker = data.tickers[0]
tv = data[ticker]
if not tv.valid:
return None
if self.bar == 5 and not self.in_position:
self.in_position = True
return buy_market(10.0, ticker=ticker)
if self.bar == 15 and self.in_position:
self.in_position = False
return close_position(ticker=ticker)
return None
def make_bar(ts, o, h, l, c, v=0.0):
b = reamer_py.OhlcvBar()
b.timestamp = ts
b.open, b.high, b.low, b.close, b.volume = o, h, l, c, v
return b
bars = {"SPX": [
make_bar("2024-01-02 09:30:00", 130.0, 131.5, 129.5, 131.0, 1_000_000),
make_bar("2024-01-03 09:30:00", 131.0, 132.0, 130.0, 131.5, 900_000),
# … add more bars
]}
cfg = reamer_py.DefaultExecutionModelConfig()
cfg.commission_per_unit = 0.01
cfg.slippage = 0.05
cfg.spread = 0.10
result = reamer_py.run_backtest(
bars_by_ticker=bars,
strategy=MyFirstStrategy(),
alignment_mode="union",
exec_config=cfg,
initial_capital=10_000.0,
leverage=1.0,
)
print(f"net_pnl={result.net_pnl:.2f} trades={result.trades}")
tick_ask + slippage on bar N — not at bar N+1's open.
Writing a strategy
The on_candle callback
Implement a class with __init__(self, config_path=None) and on_candle(self, data). The engine calls on_candle once per aligned bar. Return an order dict, a list of dicts, or None.
on_bar is a deprecated alias — the engine dispatches on_candle first and falls back to on_bar for backward compatibility. Always use on_candle in new strategies.Accessing bar data
def on_candle(self, data):
ticker = data.tickers[0] # uppercase: "SPX", "BTCUSD"
tv = data[ticker] # or: getattr(data, ticker)
if not tv.valid: # always check in union-mode multi-asset
return None
close = float(tv.close) # current bar close
high = float(tv.high)
prev_close = float(tv.close[1]) # one bar ago (None if out of range)
ts = str(tv.timestamp) # "YYYY-MM-DD HH:MM:SS"
| Field | Description |
|---|---|
tv.valid | False when this ticker has no bar at the current master timestamp (union alignment only). Always check before acting. |
tv.open, tv.high, tv.low, tv.close, tv.volume | Current bar OHLCV. Use float(tv.close) to convert. |
tv.timestamp | Timestamp string of the current bar. |
tv.close[n] | n bars ago (n=0 = current bar). Returns None if out of range. |
Loading config from JSON
import json
class MyStrategy:
def __init__(self, config_path=None):
cfg = {}
if config_path:
with open(config_path) as f:
cfg = json.load(f)
self.qty = float(cfg.get("qty", 10.0))
self.window = int(cfg.get("window", 20))
Return values
| Return | Effect |
|---|---|
None | No action this bar |
A dict | One order |
A list or tuple of dicts | Multiple orders submitted this bar |
| Integer (1 / -1 / 0) | Ignored — always return dicts |
Printing & debugging
Use print() freely inside on_candle:
- GUI (Strategy Lab): captured per step and shown in the Console tab in Replay as you step through bars.
- reamer_py:
print()output goes to your terminal or notebook output.
print(f"bar={self.bar} close={float(tv.close):.2f} pos={self.in_position}")
on_tick — per-tick callback
on_tick(self, data, tick) is called once per synthetic tick per ticker per bar. Use it when an entry condition requires intra-bar reaction — for example, when price crosses a level mid-bar.
from engine.orders import buy_market
class Strategy:
def __init__(self, config_path=None):
self.entered = False
def on_candle(self, data):
self.entered = False # reset each bar
return None
def on_tick(self, data, tick):
tv = data[tick.ticker]
if not tv.valid:
return None
threshold = float(tv.open[0]) * 0.995
if not self.entered and tick.ask < threshold:
self.entered = True
return buy_market(1.0, ticker=tick.ticker)
tick attributes
| Attribute | Type | Description |
|---|---|---|
tick.ticker | str | Ticker (uppercase) |
tick.bid | float | Current synthetic bid price |
tick.ask | float | Current synthetic ask price |
tick.mid | float | (bid + ask) / 2 |
tick.tick_index | int | 0-based position within bar (0 = open, last = close) |
tick.tick_count | int | Total ticks in this bar (= seconds between bars) |
tick.progress | float | tick_index / (tick_count − 1) — 0.0 at open, 1.0 at close |
Fill semantics
Orders from on_tick fill at tick.tick_index — not at tick 1 like on_candle orders. A market order returned at tick_index=30 fills at tick 30's bid/ask ± slippage.
Limit and stop orders from on_tick are eligible from tick_index onward within the same bar. IOC orders are cancelled at end of that tick if unfilled.
on_tick makes one Python call per synthetic tick. On daily bars that is 86,400 calls per bar. For conditions expressible as vectorized numpy operations, use on_tick_batch instead — it makes one call per bar regardless of timeframe.
on_tick runs in a pre-computation pass. Track fills manually with instance variables (e.g. self.entered) — broker feedback from prior ticks is not available within the same bar's sequence.
on_tick_batch — batched tick callback
on_tick_batch(self, data, ticks) is the high-performance alternative to on_tick. Called once per bar per ticker with all tick prices as numpy arrays — eliminating per-tick Python call overhead entirely.
import numpy as np
from engine.orders import buy_market
class Strategy:
def __init__(self, config_path=None):
self.entered = False
def on_candle(self, data):
self.entered = False
return None
def on_tick_batch(self, data, ticks):
if self.entered:
return None
threshold = float(data[ticks.ticker].open[0]) * 0.995
qualifying = np.where(ticks.ask < threshold)[0]
if not len(qualifying):
return None
self.entered = True
return (int(qualifying[0]), buy_market(1.0, ticker=ticks.ticker))
ticks attributes
| Attribute | Type | Description |
|---|---|---|
ticks.ticker | str | Ticker (uppercase) |
ticks.bid | ndarray (tick_count,) float64 | Synthetic bid at each tick |
ticks.ask | ndarray (tick_count,) float64 | Synthetic ask at each tick |
ticks.mid | ndarray (tick_count,) float64 | (bid + ask) / 2 at each tick |
ticks.tick_count | int | Total ticks in this bar |
Return value
Return None to submit no order, or (tick_index, order) to submit an order filling at that tick:
return (int(tick_index), buy_market(1.0, ticker=ticks.ticker))
# fill price = ticks.ask[tick_index] + slippage (buy)
tick_index must be in [0, tick_count). Out-of-bounds values are silently ignored. The arrays are read-only numpy views owned by C++ — do not modify them.
on_tick_batch and on_tick, only on_tick_batch is used. Define at most one.
tick_count=86400, on_tick makes 86,400 Python calls per bar; on_tick_batch makes 1. The tick arrays are computed in a tight C++ loop and handed to Python as zero-copy numpy views.
Market orders
Market orders execute within the same bar they are submitted, filling at the tick ask (buy) or tick bid (sell) plus slippage.
from engine.orders import buy_market, sell_market, close_position
buy_market(qty, *, ticker, tif=None, take_profit=None, stop_loss=None)
sell_market(qty, *, ticker, tif=None, take_profit=None, stop_loss=None)
close_position(*, ticker) # closes a long position only (submits sell qty=0)
| Order | Fill price |
|---|---|
| Buy market | tick_ask + slippage at the fill tick within bar N |
| Sell market | max(0, tick_bid − slippage) at the fill tick within bar N |
Closing positions
- Long:
close_position(ticker=t)submitssell qty=0— engine fills the full position. - Short:
buy_market(0.0, ticker=t)—close_positionsubmits a sell, which is rejected when short because sells match the existing short side.
# Long position — close it
return close_position(ticker=ticker)
# Short position — close it
return buy_market(0.0, ticker=ticker)
Timing
bar.open but is not exactly equal to it.
Limit orders
A limit order waits until the price crosses your level, then fills at the tick price ± slippage (which is at most limit_price + slippage for buys).
from engine.orders import buy_limit, sell_limit
buy_limit(price, qty, tif="GTC", *, ticker, take_profit=None, stop_loss=None, expiry_timestamp=None)
sell_limit(price, qty, tif="GTC", *, ticker, take_profit=None, stop_loss=None, expiry_timestamp=None)
| Order | Trigger | Fill price |
|---|---|---|
| Buy limit | tick_ask ≤ limit_price | tick_ask + slippage (≤ limit_price + slippage) |
| Sell limit | tick_bid ≥ limit_price | max(0, tick_bid − slippage) (≥ limit_price − slippage) |
With zero slippage the fill equals or beats the stated limit price. Limit orders are GTC by default and persist across bars until filled or cancelled.
Example: enter on pullback
from engine.orders import buy_limit, close_position
class PullbackStrategy:
def __init__(self, config_path=None):
self.in_position = False
self.has_pending = False
self._order_count = 0 # mirrors engine's sequential order ID (1-based)
def on_candle(self, data):
ticker = data.tickers[0]
tv = data[ticker]
if not tv.valid:
return None
close = float(tv.close)
if not self.in_position and not self.has_pending:
entry_price = round(close * 0.98, 2)
self._order_count += 1
self.has_pending = True
return buy_limit(entry_price, 5.0, ticker=ticker)
if self.in_position and close > self.entry_price * 1.03:
self.in_position = False
return close_position(ticker=ticker)
return None
order_id key. IDs are assigned by the engine. To cancel a pending limit later, mirror the engine's sequential counter in your strategy (as above) or look up the order in result.order_log after the run.
Limit price on wrong side
close = 130.0
buy_limit(131.0, ...) # wrong — 131 is above close; tick_ask ≤ 131 is immediately true
buy_limit(129.0, ...) # correct — waits for price to fall to 129
Stop orders
A stop order triggers when price crosses your level in the breakout direction, then fills at the tick price ± slippage.
from engine.orders import buy_stop, sell_stop
buy_stop(price, qty, tif="GTC", *, ticker, take_profit=None, stop_loss=None, expiry_timestamp=None)
sell_stop(price, qty, tif="GTC", *, ticker, take_profit=None, stop_loss=None, expiry_timestamp=None)
| Order | Trigger | Fill price |
|---|---|---|
| Buy stop | tick_ask ≥ stop_price (price rises to stop) | tick_ask + slippage |
| Sell stop | tick_bid ≤ stop_price (price falls to stop) | max(0, tick_bid − slippage) |
Limit vs. stop at a glance
| Limit | Stop | |
|---|---|---|
| Buy triggers when | price falls to or below your price | price rises to or above your price |
| Sell triggers when | price rises to or above your price | price falls to or below your price |
| Use for | entering on pullbacks | entering on breakouts |
| Fill price | tick_ask + slippage (buy) or tick_bid − slippage (sell) | same |
close = 130.0
buy_limit(128.0, ...) # fills if price drops to 128 — enter the dip
buy_stop(132.0, ...) # fills if price rises to 132 — enter the breakout
# WRONG usage:
buy_stop(128.0, ...) # stop_price=128 is below current price (130)
# tick_ask ≥ 128 is immediately true → fills as a market order
Take profit & stop loss
Attach automatic exits to any entry by passing take_profit= and/or stop_loss=. Both are optional and can be used independently.
buy_market(5.0, ticker=ticker,
take_profit=close + 3.00,
stop_loss=close - 1.50)
Bracket validation rules
| Entry side | Take profit must be | Stop loss must be |
|---|---|---|
| Buy (long) | above entry price | below entry price |
| Sell (short) | below entry price | above entry price |
Invalid brackets are rejected at order submission with reason "invalid TP/SL for side".
How bracket exits fill
Both TP and SL use the same fill formula — there is no "limit-type exact-price fill" for TP:
| Exit | Trigger | Fill price (long) | Fill price (short) |
|---|---|---|---|
| Take profit | TP level touched | max(0, tick_bid − slippage) | tick_ask + slippage |
| Stop loss | SL level touched | max(0, tick_bid − slippage) | tick_ask + slippage |
With zero slippage, the TP fill equals or exceeds the stated TP price for longs (since the trigger requires tick_bid ≥ take_profit). With nonzero slippage, both exits are reduced by slippage from the trigger tick price.
TP/SL collision
If both TP and SL are crossed within the same bar's tick sequence, whichever level is hit first in tick order wins. The tick sequence is deterministic given rng_seed — there is no fixed "TP wins" rule.
Bracket lifecycle
result.order_log. Only entry orders are logged there. Use result.closed_trades to count and inspect all completed trades including bracket exits.
# Wrong — misses bracket exits
print(len(result.order_log)) # only entry orders
# Correct — all completed trades
for trade in result.closed_trades:
print(trade.entry_price, trade.exit_price, trade.net_pnl)
Order expiry (TIF)
| TIF | Behavior |
|---|---|
"GTC" | Good Till Cancelled — stays pending until filled, cancelled, or end of data. Default. |
"IOC" | Immediate Or Cancel — attempts to fill on bar N (the same bar it is submitted). Cancelled at bar N's seal if not filled. Never persists to bar N+1. |
"GTD" | Good Till Date — requires expiry_timestamp="YYYY-MM-DD HH:MM:SS". Expired when bar_epoch ≥ expiry_epoch; status becomes "Expired". |
GTD — expire at a timestamp
from engine.orders import buy_limit
from datetime import datetime, timedelta
def on_candle(self, data):
ticker = data.tickers[0]
tv = data[ticker]
ts = str(tv.timestamp)
dt = datetime.strptime(ts, "%Y-%m-%d %H:%M:%S")
expiry = (dt + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
return buy_limit(
float(tv.close) * 0.98,
10.0,
"GTD",
ticker=ticker,
expiry_timestamp=expiry,
)
IOC — fill this bar or cancel
# Market IOC: fills within bar N's tick sequence (essentially guaranteed)
return buy_market(10.0, ticker=ticker, tif="IOC")
# Limit IOC: fills on bar N only if bar N's ticks reach your price
return buy_limit(128.0, 10.0, "IOC", ticker=ticker)
OrderStatus strings
o.status is an OrderStatus enum. String comparison is case-sensitive:
if o.status == "Filled": ... # correct — capital F
if o.status == "filled": ... # WRONG — won't match
Valid values: "Pending", "Filled", "Cancelled", "Expired", "Rejected".
Cancelling orders
Submit a cancel request by order ID. The engine assigns IDs sequentially starting from 1. Mirror the counter in your strategy to predict the next ID:
from engine.orders import buy_limit, cancel_order
class CancelExample:
def __init__(self, config_path=None):
self.pending_id = None
self.bar = 0
self._order_count = 0 # mirrors engine's 1-based sequential ID
def on_candle(self, data):
self.bar += 1
ticker = data.tickers[0]
if self.bar == 5:
self._order_count += 1
self.pending_id = self._order_count
return buy_limit(float(data[ticker].close) * 0.97, 10.0, ticker=ticker)
if self.bar == 8 and self.pending_id is not None:
oid = self.pending_id
self.pending_id = None
return cancel_order(oid, ticker=ticker)
return None
Cancelling a nonexistent or already-closed order ID is silently ignored.
Position management
One position per ticker
Each ticker can have at most one open position at a time. Submitting a same-direction order while in a position is rejected.
Reversals require two steps
Close the existing position first, then open the opposite side on the next bar:
if close < 128.0 and self.side == "long":
self.side = None
return close_position(ticker=ticker) # fills bar N, position is flat
# Next bar:
if self.side is None:
self.side = "short"
return sell_market(10.0, ticker=ticker) # bar N+1 open
Tracking position state
There is no position query API inside on_candle. Track it with instance variables:
class Strategy:
def __init__(self, config_path=None):
self.in_position = False # simple flag
self.side = None # None | "long" | "short"
self.entry_price = 0.0
Position sizing
def risk_sized_qty(entry, stop, risk_dollars):
"""Size so that hitting the stop costs exactly risk_dollars."""
risk_per_unit = abs(entry - stop)
return risk_dollars / risk_per_unit if risk_per_unit > 0 else 0.0
Partial closes
Partial closes are not supported. The engine rejects any close order whose qty doesn't exactly match the open position size (unless qty=0, which closes the full position). To "scale out," you must close the full position and re-enter with the smaller size.
Margin check
An entry is rejected if the total notional would exceed your equity × leverage:
rejected if: new_notional + existing_notional > equity × leverage
where new_notional = qty × fill_price
existing_notional = sum of all other open position notionals
Common mistakes
# Adding to a long position (rejected)
# Bar 5: buy_market(10) — long
# Bar 8: buy_market(10) — rejected: "reversal not supported: close then open"
# Closing with exact qty (accepted)
# Long 10 units
return sell_market(10.0, ticker=ticker) # accepted — closes the long
return sell_market(15.0, ticker=ticker) # rejected — "close qty must match position qty"
# Partial close (rejected)
return sell_market(5.0, ticker=ticker) # rejected — partial closes not supported
Multiple orders per bar
Return a list to submit multiple orders on the same bar:
from engine.orders import buy_limit, sell_limit, orders
def on_candle(self, data):
ticker = data.tickers[0]
tv = data[ticker]
close = float(tv.close)
return orders(
buy_limit(close - 1.0, 5.0, ticker=ticker),
sell_limit(close + 1.0, 5.0, ticker=ticker),
)
orders(*items) returns the single item if only one is passed, otherwise a list.
GUI (Desktop)
Open the Reamer application — on Windows double-click the installer shortcut, on macOS open from Applications, on Linux run the AppImage. No terminal needed.
Main workspace tabs
| Tab | Purpose |
|---|---|
| Data | Manage CSV files bound to the session. Add, remove, or refresh market data sources. |
| Strategy Lab | Edit strategy source, configure execution parameters, and click Run to execute the backtest. |
| Replay | Step through the series bar-by-bar with full execution state: positions, orders, trade history, and Console output. |
| Backtest Results | Headline metrics and equity curve for the latest completed run. |
| Monte Carlo | Distribution of 1000 bootstrapped equity curves: risk of ruin, drawdown percentiles, probability of profit. |
| Trade History | All completed round-trips with entry/exit prices, fees, slippage, PnL, and return on margin. |
Data tab
Manage the market data files for the current session:
| Button | Action |
|---|---|
| Add CSV | Open a file picker to select one or more OHLCV CSV files. Each file becomes one ticker (filename stem as symbol by default). |
| Remove | Remove selected ticker(s) from the session. The underlying file is not deleted. |
| Clear All | Unload all tickers from the current session. |
| Refresh | Re-scan the configured data folder and add any new CSV files found since the last load. |
Set a persistent data folder path in the toolbar field (or click Browse…). Reamer scans this folder at startup and loads all CSV files it contains. The path is saved between sessions.
The smart parser auto-detects delimiters, column names, and timestamp format — no preprocessing needed for data from Yahoo Finance, Alpaca, Databento, Dukascopy, IBKR, MT4/MT5, cTrader, or any CSV with standard OHLCV headers. See Market data formats for the full format reference.
Strategy Lab
Author and run strategies from the editor. The right-side Settings Panel controls engine parameters (capital, leverage, commission, slippage, spread, OHLCV type, swap rates). Load/Save writes a JSON file. The Templates menu provides built-in starter strategies.
Built-in templates
| Template | Description |
|---|---|
| SMA Crossover | Long when fast SMA crosses above slow SMA, flat otherwise. |
| Buy & Hold | Buys full capital on bar 1, holds to end — baseline benchmark. |
| Mean Reversion | Z-score entry below rolling mean; exit on reversion. |
| Multi-Asset | Portfolio demo: independent entries per ticker using data.tickers. |
| ATR Limit Bracket | Limit order entry with ATR-sized TP/SL bracket. |
| ATR Stop Bracket | Breakout stop entry with ATR-sized TP/SL bracket. |
Results tabs detail
| Tab | Contents |
|---|---|
| Backtest Results | Total return %, win rate, profit factor, Sharpe, Sortino, net EV/trade, avg/min/max hold time, recovery factor, drawdown (maximal & relative), order dispositions (filled / expired / cancelled / rejected). |
| Monte Carlo | 1000 bootstrapped equity curves: mean/median/best/worst terminal equity, probability of loss, risk of ruin, drawdown percentiles (p5/p50/p95). |
| Trade History | All completed round-trips with entry/exit prices, fees, slippage, PnL, return on margin. Sortable columns. |
Export
| Format | Contents |
|---|---|
HTML Report (.html) | Self-contained report: equity curve chart, Monte Carlo chart, performance statistics table, trade history. Open in a browser and use File → Print → Save as PDF for shareable deliverables. |
Order History CSV (.csv) | Flat log of every order event — submissions, fills, expirations, cancellations, rejections — with timestamps, ticker, order type, price, qty, and disposition. For audit trails, compliance exports, or downstream analysis. |
Market data formats
Files added through the Data tab are processed by a smart format-detection parser. No conversion or cleanup is needed for data from major providers.
Supported delimiters
Comma, tab, semicolon, pipe — auto-detected (highest-frequency character wins).
Recognized column headers (case-insensitive, any order)
| Field | Accepted header names |
|---|---|
| Date/time (combined) | date, datetime, ts, gmt_time, utc_datetime, open_time, ts_event, candle_begin_time, local_time |
| Separate time column | time, timestamp |
| Open | open, o |
| High | high, h, ask_high, bid_high |
| Low | low, l, ask_low, bid_low |
| Close | close, c, last, settle, adj close |
| Volume (optional) | volume, vol, v, tickvol, tick_volume, ticks, qty, quantity |
Extra columns beyond OHLCV are silently ignored. Files without recognized headers fall back to positional layout: date [time] open high low close [volume].
Timestamp formats auto-detected
| Format | Example | Source |
|---|---|---|
| Unix integers (s / ms / µs / ns) | 1704067200 | Databento, any Unix-epoch feed |
YYYY-MM-DD HH:MM:SS | 2024-01-15 09:30:00 | Alpaca, most REST APIs |
YYYY.MM.DD HH:MM:SS | 2024.01.15 09:30:00 | MT4 / MT5 |
YYYY/MM/DD HH:MM:SS | 2024/01/15 09:30:00 | Generic |
MM/DD/YYYY | 01/15/2024 | Yahoo Finance |
DD/MM/YYYY | 15/01/2024 | EU slash format |
DD.MM.YYYY HH:MM:SS | 15.01.2024 09:30:00 | Dukascopy |
YYYYMMDD HH:MM:SS | 20240115 09:30:00 | IBKR compact |
YYYYMMDDHHMMSS | 20240115093000 | Packed compact |
HH:MM | 09:30 | cTrader (no seconds) |
Special handling
- Databento: Unix nanoseconds + fixed-point prices (scaled by 10⁹) auto-detected and corrected.
- Duplicate timestamps: deduplicated — last row wins.
- UTF-8 BOM: stripped automatically.
- Headerless files: positional fallback —
date [time] open high low close [volume].
./reamer data.csv …) uses a simpler loader that requires exactly 7 columns in fixed order: Date Time Open High Low Close Volume with no extra columns. For non-standard files use the GUI Data tab instead.
reamer_py — constructing bars directly
No CSV parsing in reamer_py — construct OhlcvBar objects from any source:
b = reamer_py.OhlcvBar()
b.timestamp = "2024-01-15 09:30:00" # "YYYY-MM-DD HH:MM:SS" required
b.open, b.high, b.low, b.close, b.volume = 100.0, 105.0, 98.0, 103.0, 1_500_000.0
All timestamps must be in "YYYY-MM-DD HH:MM:SS" format and in ascending order within each ticker's bar list.
reamer_py
import reamer_py
from engine.orders import buy_market, close_position
class Strategy:
def __init__(self, config_path=None):
self.in_position = False
def on_candle(self, data):
ticker = data.tickers[0]
tv = data[ticker]
if not tv.valid:
return None
if not self.in_position:
self.in_position = True
return buy_market(1.0, ticker=ticker)
return None
# Build bars
def make_bar(ts, o, h, l, c, v=0.0):
b = reamer_py.OhlcvBar()
b.timestamp = ts
b.open, b.high, b.low, b.close, b.volume = o, h, l, c, v
return b
bars = {"SPX": [
make_bar("2024-01-02 09:30:00", 130.0, 131.5, 129.5, 131.0, 1_000_000),
make_bar("2024-01-03 09:30:00", 131.0, 132.0, 130.0, 131.5, 900_000),
]}
# Configure execution
cfg = reamer_py.DefaultExecutionModelConfig()
cfg.commission_per_unit = 0.01
cfg.commission_mode = "round_trip"
cfg.slippage = 0.05
cfg.spread = 0.10
cfg.ohlcv_type = "bid"
result = reamer_py.run_backtest(
bars_by_ticker=bars,
strategy=Strategy(),
alignment_mode="union",
exec_config=cfg,
initial_capital=10_000.0,
leverage=1.0,
)
print(result.net_pnl)
print(len(result.closed_trades))
Reading results
result.gross_pnl # PnL before costs
result.net_pnl # PnL after fees and slippage
result.total_fees # all commission charges (including bracket exits)
result.total_slippage_cost # all slippage costs (including bracket exits)
result.total_swap_cost # cumulative overnight swap paid (positive = cost to longs)
result.trades # int — completed round-trip count
result.closed_trades # list[ClosedTrade]
result.order_log # list[LiveOrder] — entry orders only, not bracket exits
result.open_orders_end # list[LiveOrder] — orders still pending at end of run
result.returns # list[float] — per-trade equity returns (Monte Carlo input)
win_rate_pct, sharpe, and equity_dd_max are printed to the console but are not Python attributes on BacktestResult. Compute them from result.closed_trades if needed in code.
Iterating trades
wins = sum(1 for t in result.closed_trades if t.net_pnl > 0)
total = len(result.closed_trades)
win_rate = wins / total if total > 0 else 0.0
for trade in result.closed_trades:
print(f"{trade.ticker} entry={trade.entry_price:.2f} "
f"exit={trade.exit_price:.2f} pnl={trade.net_pnl:.2f}")
Inspecting orders
for o in result.order_log:
print(o.id, o.status, o.fill_price, o.reject_reason)
filled = [o for o in result.order_log if o.status == "Filled"]
rejected = [o for o in result.order_log if o.status == "Rejected"]
Monte Carlo
After each backtest the engine bootstraps the per-trade return series 1000 times to produce synthetic equity curve distributions. Key statistics:
| Metric | What it means |
|---|---|
risk_of_ruin | Fraction of paths where equity hit zero. Healthy: < 0.05. |
profitable_runs_pct | % of 1000 simulations that ended above initial capital. Healthy: > 80%. |
ci95_floor | 95th-percentile lower bound: equity stayed above this in 95% of resampled histories. |
max_dd_p95 | Worst-case drawdown at 95th percentile — use for risk limit planning. |
Monte Carlo assumes i.i.d. returns — it does not model autocorrelation or regime change. Read alongside the primary backtest, not instead of it.
Fill prices
All fills use the prevailing synthetic tick bid or ask, adjusted for slippage. The tick sequence within each bar is deterministic given rng_seed.
| Order type | Trigger condition | Fill price |
|---|---|---|
| Market buy | always | tick_ask + slippage |
| Market sell | always | max(0, tick_bid − slippage) |
| Buy limit | tick_ask ≤ limit_price | tick_ask + slippage |
| Sell limit | tick_bid ≥ limit_price | max(0, tick_bid − slippage) |
| Buy stop | tick_ask ≥ stop_price | tick_ask + slippage |
| Sell stop | tick_bid ≤ stop_price | max(0, tick_bid − slippage) |
| Long TP exit | tick_bid ≥ take_profit | max(0, tick_bid − slippage) |
| Long SL exit | tick_bid ≤ stop_loss | max(0, tick_bid − slippage) |
| Short TP exit | tick_ask ≤ take_profit | tick_ask + slippage |
| Short SL exit | tick_ask ≥ stop_loss | tick_ask + slippage |
Fill prices can exceed the raw bar OHLCV range by up to spread + slippage in each direction. This is correct — spread and slippage are applied on top of tick price, which is clamped to [bar.low, bar.high].
Tick sequence
Each bar generates a synthetic tick sequence whose length equals the time delta to the next bar in seconds — a 1-minute bar has 60 ticks, a daily bar has 86,400. Tick 0 is exactly bar.open and tick N−1 is exactly bar.close; intermediate ticks are interpolated through the bar's high/low with small deterministic noise.
Fills never occur on tick 0. The tick loop starts at tick 1, so no order can fill before the bar's first interpolated price.
Same-bar fill semantics
An order submitted via on_candle at bar N has created_ts = bar_N.epoch and is eligible for all ticks 1 … N−1 of that same bar.
- Market orders fill at tick 1 unconditionally — no price condition. Tick 1 is the first interpolated step away from
bar.open, so the fill price closely approximates the open but is not exactly equal to it. - Limit and stop orders fill at whatever tick their price condition is first satisfied — potentially anywhere from tick 1 to tick N−1. This is the only way to achieve a condition-driven mid-bar fill.
on_candle is called once per bar, before the tick loop starts. There is no mid-bar callback, so a market order cannot be made to fill at an arbitrary tick like N/2 — use a limit or stop order with an appropriate price level for condition-driven entry timing.
Slippage cost accounting
slippage_cost = |fill_price − reference_price| × qty
# reference_price = tick_ask (buy) or tick_bid (sell)
This includes the spread contribution when OHLCV data is bid-only or ask-only.
Slippage, spread & commission
Slippage — absolute price units
slippage is a mean slippage in absolute price units (not a fraction of price or bar range).
- Equities:
0.05= 5 cents per fill - Forex (1 unit = 1 base currency):
0.0001= 1 pip - Crypto:
5.0= $5 per BTC fill
Spread — absolute price units
Direction depends on ohlcv_type:
| OHLCV type | Meaning | Buy fill | Sell fill |
|---|---|---|---|
"bid" | Data is bid prices | tick + spread (pays spread) | tick |
"ask" | Data is ask prices | tick | tick − spread (pays spread) |
"midpoint" | Data is mid prices | tick + spread/2 | tick − spread/2 |
Commission
| Mode | Open leg | Close leg |
|---|---|---|
"round_trip" | qty × rate | qty × rate |
"open_only" | qty × rate | 0 |
"close_only" | 0 | qty × rate |
Overnight swap
Charged at each calendar date boundary in the bar sequence. Long positions pay; short positions receive.
cfg.set_swap(0, 0.0) # Sunday
cfg.set_swap(1, 1.5) # Monday — $1.50 per unit held overnight
# 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat
Units and qty sizing
qty is always in individual base-asset units — not lots:
| Asset class | 1 unit means | Example |
|---|---|---|
| Equities | 1 share | SPX: qty=100 = 100 shares |
| Crypto | 1 coin / token | BTC: qty=0.5 = 0.5 BTC |
| Forex | 1 unit of base currency | EURUSD: 1 unit ≈ $0.00013 P&L per pip |
| Commodities | 1 unit of underlying | Gold: check lot definition for your feed |
Forex lot equivalents
| Lot type | Units | qty value |
|---|---|---|
| Standard lot | 100,000 | qty=100000 |
| Mini lot | 10,000 | qty=10000 |
| Micro lot | 1,000 | qty=1000 |
| Nano lot | 100 | qty=100 |
Scale commission_per_unit to match. A broker charging $7 per standard lot → commission_per_unit = 7 / 100_000 = 0.00007. Swap rates follow the same rule — if your broker quotes swap as $X per lot per night, divide by 100,000 for swap_* fields.
Execution config reference
reamer_py: set fields on
reamer_py.DefaultExecutionModelConfig() before passing to run_backtest.
| Parameter | Units | Description |
|---|---|---|
initial_capital | $ | Starting equity. |
leverage | × | Max notional: new_notional + existing_notional ≤ equity × leverage. |
commission_per_unit | $ / unit | Fixed fee per unit traded. |
commission_mode | enum | "round_trip", "open_only", "close_only". |
slippage | price units | Mean slippage per fill. Absolute, not fractional. |
spread | price units | Bid-ask spread per fill. Direction depends on ohlcv_type. |
ohlcv_type | enum | "bid", "ask", "midpoint". |
rng_seed | int | Seed for deterministic tick generation (default 42). |
price_volatility | σ | Scales slippage noise. 0 = deterministic. Only via reamer_py. |
swap_mon…swap_sun | $ / unit / night | Overnight swap by weekday. JSON keys; indexed 0=Sun…6=Sat in Python. |
Typical defaults by asset class
| Parameter | Equities | Crypto | Forex |
|---|---|---|---|
commission_per_unit | 0.01 | 0.0005 | 0.0 |
slippage | 0.05 | 5.0 | 0.0001 |
spread | 0.10 | 10.0 | 0.0002 |
leverage | 1.0 | 1.0 | 100.0 |
ohlcv_type | "bid" | "midpoint" | "bid" |
Multi-asset strategies
result = reamer_py.run_backtest(
bars_by_ticker={"SPX": spx_bars, "ETH": eth_bars},
strategy=MyStrategy(),
alignment_mode="union", # or "intersection"
exec_config=cfg,
initial_capital=100_000.0,
)
| Mode | When on_candle fires | tv.valid |
|---|---|---|
"union" | Every timestamp from any ticker | False when ticker has no bar at that step |
"intersection" | Only timestamps present in all tickers | Always True |
Union mode pattern
def on_candle(self, data):
out = []
for ticker in data.tickers:
tv = data[ticker]
if not tv.valid: # no bar for this ticker at this step
continue
close = float(tv.close)
# ... build orders for this ticker
out.append(buy_market(10.0, ticker=ticker))
return out if out else None
"union" for mixed-schedule instruments (equities + crypto). In union mode, always check tv.valid before acting — it is False when a ticker has no bar at the current step.
Ticker keys are uppercase
data.tickers returns uppercase keys. Pass them directly to order helpers — no .upper() needed:
ticker = data.tickers[0] # "SPX", "BTCUSD", …
return buy_market(10.0, ticker=ticker) # correct — no conversion
Replay & debugging
The Replay tab (GUI) advances the backtest bar-by-bar through the same execution model. Orders submitted by the strategy are pre-loaded — stepping through bars replays them so you can inspect every fill, position, and bracket exit in sequence.
Bottom panel tabs
| Tab | Contents |
|---|---|
| Positions | Open positions with size, entry price, unrealized PnL, TP/SL levels. |
| Open Orders | Pending limit and stop orders. Click ✕ to cancel. |
| Trade History | Completed round-trips with entry, exit, PnL, return. |
| Console | print() output from the strategy, one entry per bar, prefixed with the bar timestamp. |
Transport controls
↺ Rewind · |◀ Step Back · ▶ Play/Pause · ▶| Step Forward · +5 / +10 / +50 bars. Speed slider has 12 positions (800 ms/bar down to 50 bars/tick at 1 ms).
Allow Algo checkbox
When checked, the strategy's pre-computed orders are injected automatically at the correct bar. When unchecked, only manual orders from the order-entry panel are processed.
Console output format
[2024-01-02 09:30:00] bar close=130.14 in_pos=False
[2024-01-03 09:30:00] ENTRY signal at 129.99: sma_short=129.50 sma_long=128.80
[2024-01-04 09:30:00] bar close=128.51 in_pos=True
Order status strings in order table
Status values are Title case: Filled, Pending, Cancelled, Expired, Rejected. String comparisons in Python are case-sensitive: o.status == "Filled" works; "filled" does not.
Determinism
The tick sequence within each bar is seeded by rng_seed (default: 42). Re-running the same backtest always produces identical fills. Change rng_seed to test sensitivity to tick ordering.
Strategy development workflow
reamer_py: substitute reamer_py.run_backtest(...) for clicking Run, and inspect result.order_log / result.closed_trades in place of the GUI tabs.- Define your signal. Start with
on_candlereturningNonealways. Add indicator computation andprint()the values. Run and inspect the Console tab to confirm the signal fires when expected. - Add market orders. Use
buy_marketandclose_positionfirst. Verify fills in the Replay view. - Improve entry with limits or stops. Replace
buy_marketwithbuy_limit(enter on pullback) orbuy_stop(enter on breakout). Checkresult.order_logfor how many expired vs. filled. - Add TP/SL brackets. Replace manual close logic. Check
result.closed_trades— bracket exits appear there, not inorder_log. - Add GTD expiry to limit/stop orders to prevent stale fills in changed conditions.
- Tune execution config. Add realistic slippage, spread, and commission. A strategy that breaks when costs are added has thin or no edge.
- Review Monte Carlo. Check
risk_of_ruin < 0.05andprofitable_runs_pct > 80%for robustness.
Progression checklist
- Signal fires on expected bars (verified via print + Console tab)
- Market order fills are at the expected tick (verified via Replay)
- Position state is consistent — no double-entry rejections
- Limit/stop orders fill at correct prices (verified via
order_log) - Bracket exits appear in
closed_tradesnotorder_log - Performance holds up with realistic execution costs
- Monte Carlo
risk_of_ruin < 0.05
Order helpers (engine.orders)
from engine.orders import (
buy_market, sell_market, close_position,
buy_limit, sell_limit,
buy_stop, sell_stop,
cancel_order, orders,
)
| Helper | Signature |
|---|---|
buy_market | (qty, *, ticker, tif=None, take_profit=None, stop_loss=None) |
sell_market | (qty, *, ticker, tif=None, take_profit=None, stop_loss=None) |
close_position | (*, ticker) — closes a long; use buy_market(0.0) to close a short |
buy_limit | (price, qty, tif="GTC", *, ticker, take_profit=None, stop_loss=None, expiry_timestamp=None) |
sell_limit | (price, qty, tif="GTC", *, ticker, take_profit=None, stop_loss=None, expiry_timestamp=None) |
buy_stop | (price, qty, tif="GTC", *, ticker, take_profit=None, stop_loss=None, expiry_timestamp=None) |
sell_stop | (price, qty, tif="GTC", *, ticker, take_profit=None, stop_loss=None, expiry_timestamp=None) |
cancel_order | (order_id, *, ticker=None) |
orders | (*items) — returns single item or list |
order_id key — IDs are assigned by the engine. Mirror the engine's 1-based sequential counter in your strategy if you need to predict IDs for cancellation.
BacktestResult
| Field | Type | Description |
|---|---|---|
gross_pnl | float | PnL before fees and slippage |
net_pnl | float | PnL after all costs |
total_fees | float | All commission charges including bracket exits |
total_slippage_cost | float | All slippage costs including bracket exits |
total_swap_cost | float | Cumulative overnight swap (positive = cost to longs) |
trades | int | Completed round-trip count |
closed_trades | list[ClosedTrade] | All completed trades including bracket exits |
order_log | list[LiveOrder] | Strategy-submitted orders with final status; bracket exits not included |
open_orders_end | list[LiveOrder] | Orders still pending at end of run |
returns | list[float] | Per-trade equity returns (Monte Carlo input) |
LiveOrder
o.id # int — engine-assigned sequential ID (starts at 1)
o.created_timestamp # str — "YYYY-MM-DD HH:MM:SS"
o.closed_timestamp # str
o.ticker # str — uppercase
o.order_type_str # "buy", "sell", "buy_limit", "sell_limit", "buy_stop", "sell_stop"
o.tif_str # "GTC", "IOC", "GTD"
o.status # OrderStatus enum — compare with: o.status == "Filled"
o.reject_reason # str — non-empty when status is Rejected
o.qty # float
o.limit_price # float (0.0 for market orders)
o.stop_price # float (0.0 for non-stop orders)
o.take_profit # float (0.0 if not set)
o.stop_loss # float (0.0 if not set)
o.fill_price # float (0.0 if not filled)
o.fees # float
o.slippage_cost # float
ClosedTrade
ct.ticker # str — symbol
ct.side # Side enum; ct.side == "buy" works
ct.open_timestamp # str
ct.close_timestamp # str
ct.entry_price # float — includes spread and slippage
ct.exit_price # float — includes spread and slippage
ct.qty # float
ct.leverage # float
ct.margin_used # float
ct.fees # float — combined open + close commission (including bracket exits)
ct.slippage # float — combined open + close slippage cost
ct.gross_pnl # float — (exit - entry) × qty × ±1
ct.net_pnl # float — gross_pnl - fees
ct.return_pct # float — net_pnl / margin_used × 100
ct.open_step # int — bar index when position was opened (read-only)
ct.close_step # int — bar index when position was closed (read-only)
OhlcvBar
No-argument constructor — set fields individually:
b = reamer_py.OhlcvBar()
b.timestamp = "2024-01-15 09:30:00" # "YYYY-MM-DD HH:MM:SS"
b.open = 130.0
b.high = 131.5
b.low = 129.5
b.close = 131.0
b.volume = 1_500_000.0
All timestamps must be in "YYYY-MM-DD HH:MM:SS" format and in ascending order within each ticker's bar list.
DefaultExecutionModelConfig
cfg = reamer_py.DefaultExecutionModelConfig()
cfg.commission_per_unit = 0.01
cfg.commission_mode = "round_trip" # or CommissionMode.RoundTrip
cfg.slippage = 0.05
cfg.spread = 0.10
cfg.ohlcv_type = "bid" # or OhlcvType.Bid
cfg.price_volatility = 0.0 # 0 = deterministic fills
cfg.rng_seed = 42
cfg.set_swap(1, 1.5) # Monday swap: $1.50/unit/night
cfg.get_swap(1) # returns 1.5
run_backtest signature
reamer_py.run_backtest(
bars_by_ticker, # dict[str, list[OhlcvBar]]
strategy, # object with on_candle method
alignment_mode="union", # "union" | "intersection"
exec_config=DefaultExecutionModelConfig(),
initial_capital=10000.0,
leverage=1.0,
) -> BacktestResult
TickView & BatchTickView
TickView — on_tick(self, data, tick)
tick.ticker # str — uppercase ticker symbol
tick.bid # float — synthetic bid at this tick
tick.ask # float — synthetic ask at this tick
tick.mid # float — (bid + ask) / 2
tick.tick_index # int — 0-based position within bar
tick.tick_count # int — total ticks in this bar
tick.progress # float — tick_index / (tick_count − 1)
BatchTickView — on_tick_batch(self, data, ticks)
ticks.ticker # str — uppercase ticker symbol
ticks.bid # ndarray (tick_count,) float64 — bid prices, read-only
ticks.ask # ndarray (tick_count,) float64 — ask prices, read-only
ticks.mid # ndarray (tick_count,) float64 — mid prices, read-only
ticks.tick_count # int — total ticks in this bar
Return None or (tick_index, order_dict). ticks.ask[i] is identical to what on_tick would receive at tick_index == i.
Rejection reasons
When an order is rejected, o.reject_reason contains one of these strings:
| Reason string | Cause | Fix |
|---|---|---|
"reversal not supported: close then open" | Submitted a same-direction order while in a position, or tried to flip direction in one order | Close the position first; open new direction on the next bar |
"close qty must match position qty" | Close order qty doesn't match open position size | Use qty=0 for a full close, or pass the exact position size |
"insufficient margin" | new_notional + existing_notional > equity × leverage | Reduce qty or increase leverage |
"invalid TP/SL for side" | TP on wrong side of entry price, or SL on wrong side | Buy: TP > entry, SL < entry. Sell: TP < entry, SL > entry. |
"qty must be specified for entry orders (qty > 0)" | Entry submitted with qty ≤ 0 | Always pass positive qty for entries |
"no market data" | Order submitted for a ticker with no bar at the current step | Check tv.valid before submitting in union mode |
"missing ticker" | Order has no ticker field and it couldn't be inferred | Always pass ticker= explicitly |
Checking rejections after a run
rejected = [o for o in result.order_log if o.status == "Rejected"]
for o in rejected:
print(f"bar {o.created_timestamp} {o.order_type_str} reason={o.reject_reason}")
License
The Reamer GUI desktop application and the reamer_py Python package are distributed under the same license agreement. Use of either product constitutes acceptance of the license terms. Unauthorized redistribution, reverse engineering, or resale is prohibited.
For licensing enquiries contact hello@reamerlabs.com.