Running a Backtest
To perform a backtest, you need to construct a Strategy Documentation. Once the strategy is created, you can call the start! function on it to begin the backtest.
The entry function that is called in all modes is call!(s::Strategy, ts::DateTime, ctx). This function takes three arguments:
s: The strategy object that you have created.ts: The current date. In live mode, it is very close tonow(), while in simulation mode, it is the date of the iteration step.ctx: Additional context information that can be passed to the function.
During the backtest, the call! function is responsible for executing the strategy's logic at each timestep. It is called repeatedly with updated values of ts until the backtest is complete.
It is important to note that the call! function should be implemented in your strategy module according to your specific trading logic.
Backtest Configuration
Before running a backtest, you can configure various parameters to control the simulation behavior.
Performance Optimization Settings
For large backtests, consider these optimization settings.
Basic Example
Here is an example of how to use the call! function in a strategy module.
Let's run a backtest.
Our backtest indicates that our strategy:
- Operated on 3 assets (instances)
- Executed 977 trades
- Started with 100 USDT and finished with 32 USDT in cash, and assets worth 156 USDT
- The asset with the minimum value at the end was BTC, and the one with the maximum value was XMR
- At the end, there were 3 open buy orders and no open sell orders.
Comprehensive Backtest Example
Here's a more detailed example showing a complete backtesting workflow.
Tick-by-Tick Backtesting
In addition to the candle-based backtest described above, SimMode can replay the market's actual trade stream, trade by trade. Instead of iterating over candles and calling call! once per timestep, the backtest iterates over every market trade in global chronological order and calls ping! for each one.
Tick backtesting replays public trade prints. It does not reconstruct the order book — there is no bid/ask spread, no queue position, and no liquidity modeling.
Tick Data
Each asset's tick stream is a DataFrame with three columns:
| Column | Type | Description |
|---|---|---|
:timestamp | DateTime | Trade time, millisecond precision |
:price | DFT | Trade price |
:amount | DFT | Traded quantity |
Planar.Fetch.fetch_trades returns exactly this schema (paginating the exchange's fetchTrades through the ccxt gateway, sorted oldest-first and deduplicated on exact (timestamp, price, amount) triples), and Planar.Instances.setticks! stores a tick DataFrame on an AssetInstance:
using Planar
df = Planar.Fetch.fetch_trades(exc, "BTC/USDT"; since=1_700_000_000_000, limit=1000, pages=10)
Planar.Instances.setticks!(ai, df)Every asset in the strategy's universe must have tick data, ordered by timestamp, before a tick backtest can run.
Running a Tick Backtest
The strategy implements ping! instead of call!:
function ping!(s::Strategy{Sim}, ctx::TickContext, tick::TradeTick)
# React to the current market trade. Place orders with the normal order
# creation calls, using `tick.timestamp` as the order date.
nothing
endTradeTickRange(s) merges every universe asset's tick stream into a single globally time-ordered sequence of TradeTick — timestamp, asset, price, amount; there is no side field, fills depend only on the price. The merge is a stable sort, so ticks within the same millisecond keep universe order deterministically. Constructing the range throws an ArgumentError if any universe asset has no tick data.
ctx = TickContext(Sim(), TradeTickRange(s))
start!(s, ctx)
# or, equivalently:
start!(s, TradeTickRange(s))Per tick, the backtester:
- Fills any crossed limit orders (
UpdateOrdersTick): a buy limit fills whentick.price <= order price, a sell limit whentick.price >= order price. Non-triggered FOK/IOC orders are canceled; other orders stay queued. Fills execute at the exact tick price with no slippage andactual_amount=unfilled(o), so limit orders can fill partially across successive ticks. - Calls
ping!(s, ctx, tick). Market orders placed insideping!fill immediately at the current tick price with no slippage; limit orders queue and fill on later ticks when crossed.
start! accepts the same keyword arguments as the candle backtest: doreset, show_progress (:off, :minimal, or :full), and fail_fast (on false, errors are collected and logged, aborting after 10 consecutive errors to prevent infinite error loops).
Differences from Candle Backtesting
- Warmup: ticks before
first_ts + WarmupPeriod()are skipped.WarmupPeriod()defaults to the strategy's timeframe period (call!(s, ::WarmupPeriod)). - Same-millisecond ticks are valid:
UpdateOrdersTickperforms no date-change check, so multiple trades within one millisecond are all processed. - No liquidation pass: Sim has no price-based liquidation (
isliquidatableisPaper/Liveonly), so nopositions!runs per tick. - Per-asset order checks: only the current tick's asset has its pending orders checked, since its tick price is the only price that moved.
- No
trim_universe/resetctx: tick data has no OHLCV alignment to trim, andTradeTickRangeis immutable.
Orders
To place a limit order within your strategy, you call call! just like any call to the executor. Here are the arguments.
Where s is your Strategy{Sim, ...} instance, ai is the AssetInstance to which the order refers (it should be one present in your s.universe). The amount is the quantity in base currency and date should be the one fed to the call! function. During backtesting, this would be the current timestamp being evaluated, and during live trading, it would be a recent timestamp. If you look at the example strategy, ts is current and ats is available. The available timestamp ats is the one that matches the last candle that doesn't give you forward knowledge. The date given to the order call (call!) must always be the current timestamp.
A limit order call might return a trade if the order was queued correctly. If the trade hasn't completed the order, the order is queued in s.buy/sellorders[ai]. If isnothing(trade) is true, it means the order failed and was not scheduled. This can happen if the cost of the trade did not meet the asset limits, or there wasn't enough commitable cash. If instead ismissing(trade) is true, it means that the order was scheduled, but no trade has yet been performed. In backtesting, this happens if the price of the order is too low (buy) or too high (sell) for the current candle high/low prices.
Limit Order Types
In addition to GTC (Good Till Canceled) orders, there are also IOC (Immediate Or Cancel) and FOK (Fill Or Kill) orders:
- GTC (Good Till Canceled): This order remains active until it is either filled or canceled. Best for strategies that can wait for favorable prices.
- IOC (Immediate Or Cancel): This order must be executed immediately. Any portion of the order that cannot be filled immediately will be canceled. Useful for capturing immediate opportunities.
- FOK (Fill Or Kill): This order must be executed in its entirety or not at all. Ideal when you need exact position sizes.
All three are subtypes of a limit order, <: LimitOrder>. You can create them by calling call! as shown below.
Market Order Types
Market order types include:
- MarketOrder: This order is executed at the best available price in the market. Use when immediate execution is more important than price.
- LiquidationOrder: This order is similar to a MarketOrder, but its execution price might differ from the candle price due to forced liquidation mechanics.
- ReduceOnlyOrder: This is a market order that is automatically triggered when manually closing a position. Only reduces existing positions, never increases them.
All of these behave in the same way, except for the LiquidationOrder. For example, a ReduceOnlyOrder is triggered when manually closing a position, as shown below.
Market Orders
Although the ccxt library allows setting timeInForce for market orders because exchanges generally permit it, there isn't definitive information about how a market order is handled in these cases. Given that we are dealing with cryptocurrencies, some contexts like open and close times days are lost. It's plausible that timeInForce only matters when the order book doesn't have enough liquidity; otherwise, market orders are always immediate and fully filled orders. For this reason, we always consider market orders as FOK orders, and they will always have timeInForce set to FOK when executed live (through ccxt) to match the backtester.
Market orders always go through in the backtest. If the candle has no volume, the order incurs in heavy slippage, and the execution price of the trades can exceed the candle high/low price.
Checks
Before an order is created, several checks are performed to sanitize the values. For instance, if the specified amount is too small, the system will automatically adjust it to the minimum allowable amount. However, if there isn't sufficient cash after this adjustment, the order will fail. For more information on precision and limits, please refer to the ccxt documentation.
Fees
The fees are derived from the AssetInstance fees property, which is populated by parsing the ccxt data for the specific symbol. Every trade takes these fees into account.
Slippage
Slippage is factored into the trade execution process. Here's how it works for different types of orders:
Limit Orders: These can only experience positive slippage. When an order is placed and the price moves in your favor, the actual execution price becomes slightly lower (for buy orders) or higher (for sell orders). The slippage formula considers volatility (high/low) and fill ratio (amount/volume). The more volume the order takes from the candle, the lower the positive slippage will be. Conversely, higher volatility leads to higher positive slippage. Positive slippage is only added for candles that move against the order side, meaning it will only be added on red candles for buys, and green candles for sells.
Market Orders: These can only experience negative slippage. There is always a minimum slippage added, which by default corresponds to the difference between open and close prices (other formulas are available, check the API reference). On top of this, additional skew is added based on volume and volatility.
Liquidations
In isolated margin mode, liquidations are triggered by checking the LIQUIDATION_BUFFER. You can customize the buffer size by setting the value of the environment variable PLANAR_LIQUIDATION_BUFFER. This allows you to adjust the threshold at which liquidations are triggered.
To obtain more accurate estimations, you can utilize the effective funding rate. This can be done by downloading the funding rate history using the Fetch module. By analyzing the funding rate history, you can gain insights into the funding costs associated with trading in isolated margin mode.
See Also
- Exchanges - Exchange integration and configuration
- Config - Exchange integration and configuration
- Optimization - Performance optimization techniques
- Performance Issues - Troubleshooting: Performance optimization techniques
- Data Management - Guide: Data handling and management
- Exchanges - Data handling and management
Backtesting Performance
Local benchmarking indicates that the :Example strategy, which employs FOK orders, operates on three assets, trades in spot markets, and utilizes a simple logic (which can be reviewed in the strategy code) to execute orders, currently takes approximately ~8 seconds to cycle through ~1.3M * 3 (assets) ~= 3.9M candles, executing ~6000 trades on a single x86 core.
It's crucial to note that the type of orders executed and the number of trades performed can significantly impact the runtime, aside from other evident factors like additional strategy logic or the number of assets. Therefore, caution is advised when interpreting claims about a backtester's ability to process X rows in Y time without additional context. Furthermore, our order creation logic always ensures that order inputs adhere to the exchanges's limits, and we also incorporate slippage and probability calculations, enabling the backtester to be "MC simmable".
Backtesting a strategy with margin will inevitably be slower due to the need to account for all the necessary calculations, such as position states and liquidation triggers.
Performance Benchmarks
| Strategy Type | Assets | Timeframe | Candles | Trades | Time | Memory |
|---|---|---|---|---|---|---|
| Simple MA | 3 | 1h | 3.9M | 6K | 8s | 2GB |
| Complex Multi-TF | 10 | 1h/4h/1d | 12M | 15K | 45s | 6GB |
| Margin Strategy | 5 | 15m | 8M | 25K | 120s | 4GB |
| High-Freq | 1 | 1m | 2M | 50K | 30s | 1GB |
Optimization Recommendations
Data Management:
- Use Zarr format for large datasets
- Implement data chunking for memory efficiency
- Cache frequently accessed indicators
Strategy Logic:
- Minimize allocations in hot paths
- Use in-place operations where possible
- Avoid unnecessary calculations in the main loop
Order Processing:
- Batch order operations when possible
- Use appropriate order types for your strategy
- Consider order frequency impact on performance
Multi-Asset Strategies:
- Enable parallel processing for independent assets
- Balance memory usage vs. processing speed
- Consider asset correlation in optimization