Structure of a Strategy
Strategies are described in TOML files. TOML organizes settings as key-value pairs, grouped into sections between brackets. You edit values and blocks without writing code.
This manual covers only the TOML constructs used here. For a complete description of TOML syntax, refer to the official TOML documentation.
In this manual, a TOML section is called a block. Two block forms are used.
A block written with single brackets, such as [backtest], maps to a single TOML table. This kind of block represents a unique configuration in the file.
A block written with double brackets, such as [[moving_average]], maps to an array of tables. It represents a list of blocks of the same type, for example to define multiple moving averages within a single strategy.
Overall structure of a strategy
A strategy written in TOML relies on several groups of blocks.
Configuration blocks cover the strategy’s general options and backtest parameters, such as symbols, timeframes, the test period, and the initial capital. Among configuration blocks, only [backtest] is required.
Indicator blocks turn market data into named time series. A series contains one value per candle, for example a moving average value. You access a series using bracket indexing:
fastorfast[0]for the current candle,fast[1]for the previous candle. Each indicator block has anidto reference its series.The [filters] block defines validation criteria for the backtest, such as a minimum number of trades, a minimum ROI, or a maximum drawdown. If a criterion is not met, the result is excluded. Some filters can also stop the backtest while it is running.
[[objective]] blocks define one or more objectives that use backtest metrics to rank parameter combinations. The engine keeps the best results for each objective. If you do not provide any objective, a default one based on net profit is added.
Execution logic combines conditional blocks, such as [[crossover]], and action blocks, such as [[entry]] or [[close]]. Each conditional block has an
idand specifies which block to run next when the condition is satisfied. On a conditional block, the condition is evaluated on every candle. As long as it is false, execution stays on that block. As soon as it becomes true, the block is validated and execution jumps to the next block. Action blocks, by contrast, trigger a trading operation, such as opening or closing a position, and then move to the next block.
Configuration blocks
Several configuration blocks control execution and how results are stored.
- The
[general]block groups engine-wide options, such as the number of threads or the output directory. - The
[backtest]block defines the backtest setup: test period, symbols and timeframes to load, initial capital. - The
[logging]block controls logging: log destination and result display. - The
[database]block configures the SQLite databases that store results.
Here is a configuration example:
[backtest]
symbol = "KUCOIN:BTCUSDT"
timeframe = "240"
start_date = 2020-01-01
end_date = 2026-01-01
initial_capital = 1000
[logging]
file_logging_enabled = true
include_trades = true
objective_limit = 5
[database]
backtest_export_enabled = true
objective_limit = 100Indicator blocks
Indicator blocks describe series computed from prices or from other series. They have two purposes:
- expose named series (for example
fast,slow,rsi_14) that can be reused in expressions inside conditional blocks - define the hyperparameters to explore: indicator lengths, thresholds, factors, and so on.
Typical structure of an indicator block
Most indicator blocks follow the same pattern:
idis the identifier of the series, for example"fast","slow"or"rsi_14".sourceis the input series used: standard prices ("open","high","low","close", etc.) or outputs from other indicators.symbolandtimeframeare optional. They allow you to override, for this block, the symbol or timeframe defined in[backtest].- One or more numeric parameters are expressed as ranges:
- often with sub-keys such as
length.start,length.stopandlength.stepfor a window length - sometimes under different names, but always with the same
start/stop/stepstructure.
- often with sub-keys such as
- Some parameters are expressed as lists of values, for example
type = ["sma", "ema", "wma", "zlema"]to test multiple moving-average types.
Example of indicator blocks: two moving averages (fast/slow) for a crossover signal, with multiple types:
[[moving_average]]
id = "fast"
type = ["sma", "ema", "wma", "zlema"]
length.start = 2
length.stop = 100
[[moving_average]]
id = "slow"
type = ["sma", "ema", "wma", "zlema"]
length.start = 2
length.stop = 100Validating hyperparameter combinations
The [constraints] block lets you discard hyperparameter combinations that do not make sense for your strategy. Its condition is evaluated before each backtest: if it is false, the combination is ignored.
The condition key holds this logical expression. It can reference hyperparameters from indicator blocks and [[constant]] blocks.
For example, in a two-moving-average crossover (fast and slow), fast.length < slow.length ensures that fast remains the more responsive average. If multiple moving-average types are explored, fast.type = slow.type also ensures that both averages remain of the same type:
[constraints]
condition = "fast.length < slow.length and fast.type = slow.type"Optimised constants
A [[constant]] block declares a named scalar constant, independent of symbol and timeframe. During optimization, its value is explored over a range, then you can use that constant in expressions.
Each [[constant]] block contains:
id, the unique name of the constant.- A numeric range described by
start,stopand optionallystep, over which the engine will iterate.
Here is an example with two optimized constants, typically used for take profit and stop loss:
[[constant]]
id = "take_profit_pct"
start = 1
stop = 10
[[constant]]
id = "stop_loss_pct"
start = 1
stop = 10Once declared, you can use take_profit_pct and stop_loss_pct in expressions. Within each grid, one backtest is run for each combination of values of these constants.
Filters on backtest results
The [filters] block defines validation criteria for backtests. Depending on the filter, these criteria are checked either on the final result or while the backtest is still running.
Filters such as min_required_positions or min_roi_pct are evaluated once the backtest has finished. They let you discard combinations whose final result does not meet your criteria.
Other filters are used to stop a backtest as soon as continuing it no longer makes sense. This is the case for max_allowed_drawdown: if the observed maximum drawdown exceeds the configured limit, execution stops immediately.
There is also a safeguard on maximum loss relative to initial capital, controlled by max_initial_capital_loss. By default, a backtest is interrupted as soon as the loss exceeds 80% of the initial capital, even if you do not declare this value in [filters].
Example:
[filters]
min_required_positions = 30 # The backtest must open at least 30 positions.
min_roi_pct = 20.0 # Minimum ROI is 20%.
max_allowed_drawdown = 35.0 # The backtest stops if drawdown exceeds 35%.
max_initial_capital_loss = 80.0 # By default, the backtest stops once the loss exceeds 80% of the initial capital.By combining [constraints] and [filters], you reduce the search space, avoid running already invalid backtests to completion, and keep only the results that are actually relevant.
Objectives
[[objective]] blocks define the objectives used to rank the parameter combinations evaluated during an exhaustive search.
Each block provides a scoring formula through the formula key. The formula is built from backtest metrics such as profit, drawdown, common ratios, or the number of trades. The ascending key controls the sort order: false ranks the highest scores first, true the lowest.
Each objective produces a separate ranking, for example by net profit or by minimum drawdown.
If you do not declare any [[objective]] block, combinations are ranked by descending net profit by default:
[[objective]]
id = "netprofit"
formula = "netprofit"
ascending = falseThe example below defines two objectives:
return_over_ddranks combinations by the(grossprofit_percent - grossloss_percent) / max_drawdown_percentscore: it rewards return and penalises drawdown.max_profitranks combinations by descending net profit.
[[objective]]
id = "return_over_dd"
formula = "(grossprofit_percent - grossloss_percent) / max_drawdown_percent"
ascending = false
[[objective]]
id = "max_profit"
formula = "netprofit"
ascending = falseConditional graph and execution logic
Core principle
Conditional blocks form a directed graph that is evaluated candle by candle.
The engine follows this logic:
- It starts from the block identified in
[start], then moves to the block referenced bynext_block_id. - On each candle, exactly one conditional block is the current block and is evaluated.
- As long as this block is not validated, the engine advances one candle at a time and re-evaluates the same block.
- As soon as the block is validated, the engine jumps to another block, determined by
next_block_id, bythen_block_id/else_block_idfor an[[if]]block, or by the configuration of[[and]]and[[or]]blocks.
This process repeats until the end of the backtest period or until an explicit stop is reached.
In practice, the main path of your strategy forms a loop.
After opening, managing, and eventually closing positions, the graph loops back to a previously visited block, often [start] or a setup block.
Graph entry point
The [start] block defines the exact entry point of the conditional graph.
This block is unique and minimal:
idis the logical identifier of the start node.next_block_idpoints to the first conditional block that will actually be evaluated.
[start]
id = "origin"
next_block_id = "first_condition"Identifiers and links between blocks
Each conditional block has a unique id.
Links between blocks are expressed in several ways:
Most blocks use
next_block_idto indicate the block to visit once the current block is validated.Example:
[[crossover]] id = "golden_cross" reference = "fast" comparison = "slow" next_block_id = "enter_long"[[if]]blocks usethen_block_idandelse_block_idto distinguish the true and false branches.Example:
[[if]] id = "trend_switch" condition = "rsi_fast > rsi_slow" then_block_id = "enter_long" else_block_id = "enter_short"[[and]]blocks group a list of child blocks that must all be true on the same candle.Example:
[[and]] id = "entry_filters" conditions = ["ma_filter", "rsi_filter"] next_block_id = "entry"[[or]]blocks group multiple child blocks. As soon as at least one child becomes true, the[[or]]block validates and follows its ownnext_block_id.Example:
[[or]] id = "monitor_exits" conditions = ["take_profit_hit", "stop_loss_hit", "death_cross"] next_block_id = "close_position"
Main families of conditional blocks
Conditional blocks can be grouped into three broad families.
Simple tests
These blocks evaluate local conditions on series or on the position:
[[condition]]evaluates a free-form boolean expression on the available series.[[trend]]compares a numeric expression on the current candle with the same expression on the previous candle.[[threshold]],[[position]], and[[trailing]]compare series with levels or detect relative reversals.[[crossover]],[[crossunder]]and[[cross]]detect a crossover between two series or expressions.[[wait]]introduces an explicit delay by waiting for a certain number of candles.[[variable]]updates a variable each time execution reaches the block using a formula.
Note: [[condition]] is the generic block. [[trend]], [[threshold]], and [[position]] are convenience blocks and can often be rewritten with a [[condition]] block that states the intended comparison explicitly. By contrast, [[trailing]], [[crossover]], [[crossunder]], and [[cross]] encapsulate inter-candle state logic and are not always replaceable by a simple static condition.
Logical composition
These blocks organise several conditions together:
[[and]]groups multiple child blocks that must all be true on the same candle.[[or]]tests several child blocks in order and moves to its ownnext_block_idas soon as at least one child validates.[[if]]picks between two target blocks depending on whether a condition is true or false.
Position actions
These blocks open, manage, or close positions, and can also create, update, or cancel orders:
[[entry]]schedules an opening or an increase of a position.[[order]]schedules a raw buy or sell order.[[exit]]places, updates, or cancels price-based exit orders (take profit, stop loss, trailing).[[close]]closes all or part of the current position.[[close_all]]closes the entire position (both long and short).[[cancel]]cancels pending orders associated with a givenorder_id.[[cancel_all]]cancels all pending orders.
Delay and deferred validation
Some blocks do not immediately move to the next block when they are reached.
Instead, they introduce a delay: the action is triggered on a given candle, and the block remains active for a number of candles before it is considered validated.
The
[[wait]]block waits for a fixed number of candles given bywait_candles(default is1) before validating and moving on to the next block.
During this wait, no other block is evaluated: the engine simply advances one candle at a time through the price series.[[entry]]and[[order]]blocks expose thewait_candlesfield (default value1):- On the candle where the engine reaches the block, the order is queued for execution.
- In backtests, this delay gives the simulator one update cycle to open the position and refresh
position_avg_price.
More generally, the block remains active for
wait_candlescandles after the trigger candle, then passes control to the next block.- This delay only affects the transition to the next block; it does not change order execution.
If you set
wait_candles = 0, there is no delay: after queuing the order, the block validates on the current candle and the next block is evaluated immediately.[[exit]],[[close]],[[close_all]],[[cancel]], and[[cancel_all]]blocks keepwait_candlesat0by default for immediate reaction.
Complete example
Here is an example of a classic crossover strategy: it opens a position when the “fast” moving average crosses above the “slow” moving average (a golden cross), then closes the position when the opposite crossover occurs (a death cross). The type and length of the two moving averages are not fixed in advance: the optimizer explores multiple types (sma, ema, wma, zlema) and lengths between 2 and 100 periods, under the constraint fast.length < slow.length and fast.type = slow.type.
# Illustrative example.
# Do not use for live trading.
# Public domain (CC0 1.0).
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
[backtest]
symbol = "KUCOIN:BTCUSDT"
timeframe = "240"
start_date = 2022-01-01
end_date = 2026-01-01
initial_capital = 1000
[logging]
file_logging_enabled = true
include_trades = true
[database]
backtest_export_enabled = true
objective_limit = 100
# -----------------------------------------------------------------------------
# Indicators
# -----------------------------------------------------------------------------
# These two blocks define two moving averages (fast/slow).
# Here, the exploration covers multiple types (sma, ema, wma, zlema) and multiple lengths.
# For each block:
# - id is the name of the indicator, used in the rest of the strategy
# - type lists the moving-average types to test
# - length.start and length.stop define the range of lengths to test
[[moving_average]]
id = "fast"
type = ["sma", "ema", "wma", "zlema"]
length.start = 2
length.stop = 100
[[moving_average]]
id = "slow"
type = ["sma", "ema", "wma", "zlema"]
length.start = 2
length.stop = 100
# Specifies which combinations of hyperparameters are valid. Here, we require
# the "fast" moving average to be shorter than the "slow" one, and both blocks
# to use the same moving-average type.
[constraints]
condition = "fast.length < slow.length and fast.type = slow.type"
# -----------------------------------------------------------------------------
# Optimised constants
# -----------------------------------------------------------------------------
[[constant]]
id = "take_profit_pct"
start = 1
stop = 10
[[constant]]
id = "stop_loss_pct"
start = 1
stop = 10
# -----------------------------------------------------------------------------
# Filters
# -----------------------------------------------------------------------------
[filters]
min_required_positions = 30 # The backtest must open at least 30 positions.
min_roi_pct = 20.0 # Minimum ROI is 20%.
max_allowed_drawdown = 35.0 # Drawdown must not exceed 50%.
# -----------------------------------------------------------------------------
# Objectives
# -----------------------------------------------------------------------------
[[objective]]
id = "return_over_dd"
formula = "(grossprofit_percent - grossloss_percent) / max_drawdown_percent"
ascending = false
[[objective]]
id = "max_profit"
formula = "netprofit"
ascending = false
# -----------------------------------------------------------------------------
# Execution logic: conditional blocks
# -----------------------------------------------------------------------------
# The [start] block is the entry point of the strategy.
# It is "auto-validated": it is always considered true and simply redirects to
# the first conditional block.
[start]
id = "origin"
next_block_id = "golden_cross" # After "origin", we move to the "golden_cross" block
# Waits for the "fast" moving average to cross above the "slow" one. Until this
# happens, execution just advances to the next bar while staying on this block.
# Once the crossover is detected, the strategy moves to the "enter_long"
# block.
[[crossover]]
id = "golden_cross"
reference = "fast"
comparison = "slow"
next_block_id = "enter_long"
# Opens a long position. Once the position is open, the strategy monitors
# multiple exit conditions via the "monitor_exits" block.
[[entry]]
id = "enter_long"
order_id = "main"
direction = "long"
next_block_id = "monitor_exits"
# Monitors exit conditions. On each bar, this block checks take profit, then
# stop loss, then the inverse crossover (death cross). As soon as one of these
# blocks validates, the strategy moves to "close_position".
[[or]]
id = "monitor_exits"
conditions = ["take_profit_hit", "stop_loss_hit", "death_cross"]
next_block_id = "close_position"
# Take profit: validates when the close price reaches a multiple of the average
# entry price based on take_profit_pct.
[[condition]]
id = "take_profit_hit"
condition = "close >= position_avg_price * (1 + take_profit_pct / 100)"
# Stop loss: validates when the close price reaches a threshold below the
# average entry price based on stop_loss_pct.
[[condition]]
id = "stop_loss_hit"
condition = "close <= position_avg_price * (1 - stop_loss_pct / 100)"
# Inverse crossover (death cross): if "fast" crosses back below "slow", the
# strategy closes the position.
[[crossunder]]
id = "death_cross"
reference = "fast"
comparison = "slow"
# Closes the open position, then sends execution back to the "golden_cross" block.
# The strategy returns to its initial state and waits for a new signal.
[[close]]
id = "close_position"
order_id = "main"
next_block_id = "golden_cross"