Documentation

Strategy SDK & User Guide

Build, test, and analyse trading strategies using the Reamer GUI desktop application or the reamer_py Python wheel.

Covers installation, the Python strategy interface, all order types, execution rules, backtesting (GUI / reamer_py), replay, and the full API reference.

Installation

GUI Desktop

Reamer is distributed as a self-contained desktop application. No Python installation, no terminal, no configuration required.

PlatformDistributionHow to run
Windows.exe installerRun the installer, then launch Reamer from the Start menu
macOS.dmgOpen the DMG, drag Reamer to Applications, double-click to open
Linux.AppImagechmod +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

  1. 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.
  2. 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.
  3. 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)

  1. Install: download the .whl for your platform from GitHub Releases and run pip install reamer_py-VERSION-PLATFORM.whl
  2. 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}")
Key rule: Orders submitted on bar N fill within bar N's own tick sequence (same-bar fill). A market order fills at 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.

Note: 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"
FieldDescription
tv.validFalse 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.volumeCurrent bar OHLCV. Use float(tv.close) to convert.
tv.timestampTimestamp 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

ReturnEffect
NoneNo action this bar
A dictOne order
A list or tuple of dictsMultiple 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

AttributeTypeDescription
tick.tickerstrTicker (uppercase)
tick.bidfloatCurrent synthetic bid price
tick.askfloatCurrent synthetic ask price
tick.midfloat(bid + ask) / 2
tick.tick_indexint0-based position within bar (0 = open, last = close)
tick.tick_countintTotal ticks in this bar (= seconds between bars)
tick.progressfloattick_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.

Performance: 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.
Position state: 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

AttributeTypeDescription
ticks.tickerstrTicker (uppercase)
ticks.bidndarray (tick_count,) float64Synthetic bid at each tick
ticks.askndarray (tick_count,) float64Synthetic ask at each tick
ticks.midndarray (tick_count,) float64(bid + ask) / 2 at each tick
ticks.tick_countintTotal 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.

Priority: if a strategy defines both on_tick_batch and on_tick, only on_tick_batch is used. Define at most one.
Performance: for daily bars with 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)
OrderFill price
Buy markettick_ask + slippage at the fill tick within bar N
Sell marketmax(0, tick_bid − slippage) at the fill tick within bar N

Closing positions

  • Long: close_position(ticker=t) submits sell qty=0 — engine fills the full position.
  • Short: buy_market(0.0, ticker=t)close_position submits 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 N: on_candle sees bar N data → returns buy_market(10.0) Bar N: order fills within bar N's tick sequence at tick_ask + slippage ← you are now long from within bar N
Common mistake — expecting a fill at bar N+1's open: fills happen within bar N. A market order fills at tick 1 of bar N — the first interpolated tick, which closely approximates 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)
OrderTriggerFill price
Buy limittick_ask ≤ limit_pricetick_ask + slippage (≤ limit_price + slippage)
Sell limittick_bid ≥ limit_pricemax(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 dicts don't carry an 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)
OrderTriggerFill price
Buy stoptick_ask ≥ stop_price (price rises to stop)tick_ask + slippage
Sell stoptick_bid ≤ stop_price (price falls to stop)max(0, tick_bid − slippage)

Limit vs. stop at a glance

LimitStop
Buy triggers whenprice falls to or below your priceprice rises to or above your price
Sell triggers whenprice rises to or above your priceprice falls to or below your price
Use forentering on pullbacksentering on breakouts
Fill pricetick_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 sideTake profit must beStop loss must be
Buy (long)above entry pricebelow entry price
Sell (short)below entry priceabove 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:

ExitTriggerFill price (long)Fill price (short)
Take profitTP level touchedmax(0, tick_bid − slippage)tick_ask + slippage
Stop lossSL level touchedmax(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

Bar N: buy_market(5.0, take_profit=133.0, stop_loss=127.0) Bar N: fills within bar N's ticks at tick_ask + slippage ≈ 130.00 ← bracket arms: TP=133, SL=127 Bar N+1: (price between 127–133 — bracket resting) Bar N+2: high reaches 133.10 → TP triggered fills at tick_bid − slippage ≈ 133.00 (or: low reaches 126.80 → SL fills at tick_bid − slippage ≈ 126.8x)
Bracket exits do not appear in 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)

TIFBehavior
"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)
IOC timing: IOC fills on bar N (same bar submitted), not on bar N+1. If unfilled at bar N's seal it is cancelled. It never reaches bar N+1.

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.

Long 10 SPX → buy_market(10) → rejected ("reversal not supported: close then open") Long 10 SPX → close_position → flat Flat → buy_market(10) → accepted

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

TabPurpose
DataManage CSV files bound to the session. Add, remove, or refresh market data sources.
Strategy LabEdit strategy source, configure execution parameters, and click Run to execute the backtest.
ReplayStep through the series bar-by-bar with full execution state: positions, orders, trade history, and Console output.
Backtest ResultsHeadline metrics and equity curve for the latest completed run.
Monte CarloDistribution of 1000 bootstrapped equity curves: risk of ruin, drawdown percentiles, probability of profit.
Trade HistoryAll 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:

ButtonAction
Add CSVOpen a file picker to select one or more OHLCV CSV files. Each file becomes one ticker (filename stem as symbol by default).
RemoveRemove selected ticker(s) from the session. The underlying file is not deleted.
Clear AllUnload all tickers from the current session.
RefreshRe-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

TemplateDescription
SMA CrossoverLong when fast SMA crosses above slow SMA, flat otherwise.
Buy & HoldBuys full capital on bar 1, holds to end — baseline benchmark.
Mean ReversionZ-score entry below rolling mean; exit on reversion.
Multi-AssetPortfolio demo: independent entries per ticker using data.tickers.
ATR Limit BracketLimit order entry with ATR-sized TP/SL bracket.
ATR Stop BracketBreakout stop entry with ATR-sized TP/SL bracket.

Results tabs detail

TabContents
Backtest ResultsTotal 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 Carlo1000 bootstrapped equity curves: mean/median/best/worst terminal equity, probability of loss, risk of ruin, drawdown percentiles (p5/p50/p95).
Trade HistoryAll completed round-trips with entry/exit prices, fees, slippage, PnL, return on margin. Sortable columns.

Export

FormatContents
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)

FieldAccepted header names
Date/time (combined)date, datetime, ts, gmt_time, utc_datetime, open_time, ts_event, candle_begin_time, local_time
Separate time columntime, timestamp
Openopen, o
Highhigh, h, ask_high, bid_high
Lowlow, l, ask_low, bid_low
Closeclose, 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

FormatExampleSource
Unix integers (s / ms / µs / ns)1704067200Databento, any Unix-epoch feed
YYYY-MM-DD HH:MM:SS2024-01-15 09:30:00Alpaca, most REST APIs
YYYY.MM.DD HH:MM:SS2024.01.15 09:30:00MT4 / MT5
YYYY/MM/DD HH:MM:SS2024/01/15 09:30:00Generic
MM/DD/YYYY01/15/2024Yahoo Finance
DD/MM/YYYY15/01/2024EU slash format
DD.MM.YYYY HH:MM:SS15.01.2024 09:30:00Dukascopy
YYYYMMDD  HH:MM:SS20240115 09:30:00IBKR compact
YYYYMMDDHHMMSS20240115093000Packed compact
HH:MM09:30cTrader (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].
GUI vs CLI: the Data tab uses the smart parser above. The CLI (./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)
Metrics like 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:

MetricWhat it means
risk_of_ruinFraction of paths where equity hit zero. Healthy: < 0.05.
profitable_runs_pct% of 1000 simulations that ended above initial capital. Healthy: > 80%.
ci95_floor95th-percentile lower bound: equity stayed above this in 95% of resampled histories.
max_dd_p95Worst-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 typeTrigger conditionFill price
Market buyalwaystick_ask + slippage
Market sellalwaysmax(0, tick_bid − slippage)
Buy limittick_ask ≤ limit_pricetick_ask + slippage
Sell limittick_bid ≥ limit_pricemax(0, tick_bid − slippage)
Buy stoptick_ask ≥ stop_pricetick_ask + slippage
Sell stoptick_bid ≤ stop_pricemax(0, tick_bid − slippage)
Long TP exittick_bid ≥ take_profitmax(0, tick_bid − slippage)
Long SL exittick_bid ≤ stop_lossmax(0, tick_bid − slippage)
Short TP exittick_ask ≤ take_profittick_ask + slippage
Short SL exittick_ask ≥ stop_losstick_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 typeMeaningBuy fillSell fill
"bid"Data is bid pricestick + spread (pays spread)tick
"ask"Data is ask pricesticktick − spread (pays spread)
"midpoint"Data is mid pricestick + spread/2tick − spread/2

Commission

ModeOpen legClose leg
"round_trip"qty × rateqty × rate
"open_only"qty × rate0
"close_only"0qty × 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 class1 unit meansExample
Equities1 shareSPX: qty=100 = 100 shares
Crypto1 coin / tokenBTC: qty=0.5 = 0.5 BTC
Forex1 unit of base currencyEURUSD: 1 unit ≈ $0.00013 P&L per pip
Commodities1 unit of underlyingGold: check lot definition for your feed

Forex lot equivalents

Lot typeUnitsqty value
Standard lot100,000qty=100000
Mini lot10,000qty=10000
Micro lot1,000qty=1000
Nano lot100qty=100

Scale commission_per_unit to match. A broker charging $7 per standard lotcommission_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

GUI: adjust all parameters in the Settings Panel (right side of Strategy Lab) or use Load/Save JSON.
reamer_py: set fields on reamer_py.DefaultExecutionModelConfig() before passing to run_backtest.
ParameterUnitsDescription
initial_capital$Starting equity.
leverage×Max notional: new_notional + existing_notional ≤ equity × leverage.
commission_per_unit$ / unitFixed fee per unit traded.
commission_modeenum"round_trip", "open_only", "close_only".
slippageprice unitsMean slippage per fill. Absolute, not fractional.
spreadprice unitsBid-ask spread per fill. Direction depends on ohlcv_type.
ohlcv_typeenum"bid", "ask", "midpoint".
rng_seedintSeed for deterministic tick generation (default 42).
price_volatilityσScales slippage noise. 0 = deterministic. Only via reamer_py.
swap_monswap_sun$ / unit / nightOvernight swap by weekday. JSON keys; indexed 0=Sun…6=Sat in Python.

Typical defaults by asset class

ParameterEquitiesCryptoForex
commission_per_unit0.010.00050.0
slippage0.055.00.0001
spread0.1010.00.0002
leverage1.01.0100.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,
)
ModeWhen on_candle firestv.valid
"union"Every timestamp from any tickerFalse when ticker has no bar at that step
"intersection"Only timestamps present in all tickersAlways 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
Use "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

TabContents
PositionsOpen positions with size, entry price, unrealized PnL, TP/SL levels.
Open OrdersPending limit and stop orders. Click ✕ to cancel.
Trade HistoryCompleted round-trips with entry, exit, PnL, return.
Consoleprint() 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

Steps below reference GUI features (Strategy Lab, Replay, Console tab). If using reamer_py: substitute reamer_py.run_backtest(...) for clicking Run, and inspect result.order_log / result.closed_trades in place of the GUI tabs.
  1. Define your signal. Start with on_candle returning None always. Add indicator computation and print() the values. Run and inspect the Console tab to confirm the signal fires when expected.
  2. Add market orders. Use buy_market and close_position first. Verify fills in the Replay view.
  3. Improve entry with limits or stops. Replace buy_market with buy_limit (enter on pullback) or buy_stop (enter on breakout). Check result.order_log for how many expired vs. filled.
  4. Add TP/SL brackets. Replace manual close logic. Check result.closed_trades — bracket exits appear there, not in order_log.
  5. Add GTD expiry to limit/stop orders to prevent stale fills in changed conditions.
  6. Tune execution config. Add realistic slippage, spread, and commission. A strategy that breaks when costs are added has thin or no edge.
  7. Review Monte Carlo. Check risk_of_ruin < 0.05 and profitable_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_trades not order_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,
)
HelperSignature
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 dicts returned by these helpers do not contain an 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

FieldTypeDescription
gross_pnlfloatPnL before fees and slippage
net_pnlfloatPnL after all costs
total_feesfloatAll commission charges including bracket exits
total_slippage_costfloatAll slippage costs including bracket exits
total_swap_costfloatCumulative overnight swap (positive = cost to longs)
tradesintCompleted round-trip count
closed_tradeslist[ClosedTrade]All completed trades including bracket exits
order_loglist[LiveOrder]Strategy-submitted orders with final status; bracket exits not included
open_orders_endlist[LiveOrder]Orders still pending at end of run
returnslist[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 stringCauseFix
"reversal not supported: close then open"Submitted a same-direction order while in a position, or tried to flip direction in one orderClose the position first; open new direction on the next bar
"close qty must match position qty"Close order qty doesn't match open position sizeUse qty=0 for a full close, or pass the exact position size
"insufficient margin"new_notional + existing_notional > equity × leverageReduce qty or increase leverage
"invalid TP/SL for side"TP on wrong side of entry price, or SL on wrong sideBuy: TP > entry, SL < entry. Sell: TP < entry, SL > entry.
"qty must be specified for entry orders (qty > 0)"Entry submitted with qty ≤ 0Always pass positive qty for entries
"no market data"Order submitted for a ticker with no bar at the current stepCheck tv.valid before submitting in union mode
"missing ticker"Order has no ticker field and it couldn't be inferredAlways 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.