Skip to content
Level 4 · Trading System ResearcherLessonPart 27 · page 3 of 530 min
30Minutes
8AFL functions
6Sources
StandardRequires
AFL functions taught here8

From Rules to AFL

The gap between a rule and a trade is one bar wide, and almost every unrealistic backtest in existence lives in that gap. Your rule is computed from a closing price. The closing price is known only once the session has ended. Any simulation that then buys at that same close has placed an order using information that did not exist when the order would have had to be sent.

This lesson covers the AFL surface that closes the gap: the four signal variables, the four trade price arrays, and SetTradeDelays(). It is a small surface — five or six lines — and getting it right is worth more than any indicator you will ever add.

AmiBroker’s backtester reads four reserved array variables. They are variables, not functions: there is no Buy() page in the AFL Function Reference, because Buy is something you assign to. The User’s Guide defines them as follows.

Variable Meaning
Buy Enter a long position
Sell Close a long position
Short Enter a short position (short sell)
Cover Close a short position (buy to cover)

Two consequences follow immediately, and both catch people out.

Sell does not open a short. It closes a long. Before version 3.59 the same two variables served both directions; now they are separate. If you assign only Buy and Sell, you have specified a long-only system, and the backtester will simulate long trades only. That is a perfectly good thing to want — it is what the project in this part does — but it should be a choice you made rather than an omission you did not notice.

A stop-and-reverse system is written explicitly. The guide’s own idiom for “always in the market” is to assign the exit of one direction as the entry of the other:

Fragment — not a complete formula

Short = Sell;
Cover = Buy;

Each of the four is an array with one value per bar. A non-zero value on a bar is a signal on that bar. And here is the part that governs everything else in this lesson:

If your entry condition stays true for eleven consecutive bars, Buy contains eleven 1s. In AmiBroker’s default backtest mode, backtestRegular, redundant entry signals occurring after the first entry and before the matching exit are removed exactly the way ExRem() removes them. The other documented modes — backtestRegularRaw and its relatives — keep them, and Part 28 explains when you would want that.

Because the removal happens inside the engine, a chart of your formula shows eleven arrows while the report shows one trade. Applying ExRem() yourself makes the two agree:

Fragment — not a complete formula

Buy = ExRem( Buy, Sell );
Sell = ExRem( Sell, Buy );

This does not change the trades in the default mode. It changes what you can see, which is worth a line.

BuyPrice, SellPrice, ShortPrice and CoverPrice say at what price each kind of order is simulated as filled. AmiBroker pre-fills all four from the trade price fields in the Analysis settings, so assigning them is optional — and, exactly because it is optional, the default is frequently something nobody chose.

Fragment — not a complete formula

BuyPrice = Open;
SellPrice = Open;

Three documented behaviours matter here.

Price-bound checking. During backtesting AmiBroker checks whether the value you assigned fits inside the bar’s high-low range, and adjusts it if not: up to the high if your value was above the high, down to the low if it was below the low. This is what makes the guide’s simulated-stop-order idiom work, and it is also why a slippage adjustment can be silently truncated on a bar that opens at its own extreme. You can switch the checking off with SetOption( "PriceBoundChecking", False ), which you should almost never do, because the result is a fill at a price the instrument never traded at.

The price arrays are not shifted by trade delays. Only the four signal arrays are. This is the single most important sentence in the lesson and the next section is about it.

TickSize does not round these arrays. Per the guide, tick size affects only exits generated by the built-in stops; the backtester assumes the prices you supply already respect the instrument’s tick size.

Fragment — not a complete formula

SetTradeDelays( buydelay, selldelay, shortdelay, coverdelay );

Four arguments, in that order, all required. The function returns nothing and overrides the trade delays configured on the Settings page.

The AFL Function Reference is unusually explicit about the mechanism. Trade delays literally apply this, inside the backtester, after your formula has been executed but before trade simulation starts:

Fragment — not a complete formula

Buy = Ref( Buy, -buydelay );
Sell = Ref( Sell, -selldelay );
Short = Ref( Short, -shortdelay );
Cover = Ref( Cover, -coverdelay );

Ref( array, -1 ) returns the value from one bar earlier, so a signal that was on bar t now sits on bar t+1. The documentation states that this is functionally equivalent to putting those four lines at the end of your own formula.

SetTradeDelays( 1, 1, 1, 1 ) on a buy signal

The rule fired on Tuesday's close of 50.40. The engine acts on Wednesday and fills at Wednesday's open of 50.90 — 0.99% worse, and that is a normal outcome rather than a bad one.
BarMonTueWedThu
Close49.650.451.150.8
Open49.549.850.951.0
Buy (your formula)0100
Buy (after delay, inside the engine)0010
BuyPrice = Open (never shifted)49.549.850.951.0
The rule fired on Tuesday's close of 50.40. The engine acts on Wednesday and fills at Wednesday's open of 50.90 — 0.99% worse, and that is a normal outcome rather than a bad one. Prices in this diagram are invented for the illustration. They are not market data and nothing should be inferred from them.

The fill price is whatever BuyPrice holds on the bar the shifted signal lands on. That is why BuyPrice = Open with a one-bar delay means “next bar’s open”, even though nothing in the assignment mentions delay: the delay moved the signal, and the signal picks its price from the bar it now sits on.

This is the standard end-of-day configuration, and it is standard because it is the only one that corresponds to something a person with a daily database could actually do.

One trading decision, laid out in real time

Bar t
Session tradesClose prints
Overnight
Data download, formula runs, order placed
Bar t+1
Opening auction: the fillSession continues
Everything the rule uses is on the left of the close. Everything the trade touches is on the right of it.

In AFL that is three lines:

Fragment — not a complete formula

SetTradeDelays( 1, 1, 1, 1 );
BuyPrice = Open;
SellPrice = Open;

What it assumes, and what you are therefore claiming when you publish a result from it:

  • You could download and process the day’s data after the close and before the next open.
  • Your order reached the market in time for the opening auction.
  • You were filled in full, at the printed opening price, with no slippage. (That last one is the subject of the next lesson, and it is not true.)
  • The overnight gap is taken as it comes. This is a feature: gap risk is real, and a fill-at-close backtest hides it entirely.

Strictly, SetTradeDelays() is optional in the sense that the compiler does not require it — if you leave it out, the backtester uses whatever the Settings page holds, which may be whatever the last person to open that dialog left there. That is a good reason to write it into every system formula regardless: a formula that states its own delays is reproducible on somebody else’s installation, and one that does not is not.

The substantive reason is different. Consider the zero-delay, fill-at-close configuration:

Fragment — not a complete formula

// A rule computed FROM the close, filled AT the close, with no delay.
SetTradeDelays( 0, 0, 0, 0 );
Buy = Cross( Close, MA( Close, 50 ) );
BuyPrice = Close;

To take that trade, you would have to know the closing price before the close in order to compute the signal, and then trade at that same close. The information required to act arrives at the moment acting becomes impossible. It is not a small approximation; it is a circular one, and it flatters results systematically, because the very bars on which a rule fires are bars on which price moved.

There are configurations with zero delay that are defensible, and it is worth being precise rather than superstitious about it:

Configuration Defensible? Why
Signal from bar t’s close, delay 1, fill at bar t+1’s open Yes Everything the signal uses precedes everything the fill touches
Signal from bar t-1’s data only, delay 0, fill at bar t’s open Yes The signal was computable before bar t began; the delay is in the formula rather than in the setting
Signal from bar t’s close, delay 0, fill at bar t’s close No Requires knowing the close before it exists
Signal from bar t’s close, delay 0, fill at bar t’s open No Worse: it fills at a price that had already printed hours before the signal
Stop or limit level set from bar t-1, delay 0, fill at that level on bar t Yes, with care The order rests in the market; the price array holds a level, not an observed close, and price-bound checking keeps it inside the bar

Trading on the close is possible in real life — market-on-close orders exist. But then the signal must be computable before the close, which means computing it from the previous bar’s data or from an intraday snapshot taken before the auction. That is a different formula, not a different setting, and Part 23 deals with the intraday case.

Assemble the smallest formula that is a system rather than an indicator: it enters, it exits, it says at what price, and it says how long after the signal. Nothing else. Getting this to run and produce a trade list you can reconcile by hand is the milestone; realism comes next lesson.

Complete runnable AFL

minimal-system.afl
// minimal-system.afl
// Part 27 - From Rules to AFL
//
// The smallest formula that is a trading system rather than an indicator. It
// says when to enter, when to exit, at what price a trade is simulated, and how
// many bars after the signal the trade happens. Nothing else.
//
// It is deliberately incomplete as RESEARCH. There is no position sizing, no
// commission, no slippage and no liquidity floor, so any figure it produces is
// a description of a frictionless market that does not exist. Those pieces are
// added in execution-assumptions.afl. Do not quote a number from this file.
//
// EXECUTION ASSUMPTIONS
// Interval Daily bars, split- and dividend-adjusted.
// Signal computed On the close of bar t, from data up to and including bar t.
// Order placed After the close of bar t.
// Fill The opening print of bar t+1, in full, at the printed open.
// Delays SetTradeDelays( 1, 1, 1, 1 ) - one bar on all four signals.
// Direction Long only. Short and Cover are left unassigned, so the
// backtester simulates long trades only.
// Costs NONE MODELLED.
// Liquidity NOT CHECKED. The formula will happily "trade" a symbol
// whose entire daily volume is smaller than one order.
//
// How to run it:
// Formula Editor -> paste -> Tools -> Send to Analysis
// Apply to: Current symbol, or a small watch list
// Range: All quotations
// Settings: Periodicity = Daily
// Then press Backtest.
MaPeriod = 100;
TrendLine = MA( Close, MaPeriod );
// Entry and exit are EVENTS, not states. Cross() is true only on the bar the
// relationship changed. Writing Buy = Close > TrendLine instead would be true
// on every bar of an advance, which is a different rule producing different
// trades - see the ambiguity discussion in the previous lesson.
Buy = Cross( Close, TrendLine );
Sell = Cross( TrendLine, Close );
// One bar of delay on every signal. SetTradeDelays shifts Buy/Sell/Short/Cover
// inside the backtester after this formula has run. It shifts nothing else -
// the price arrays below are NOT shifted - so BuyPrice = Open means "the open
// of whichever bar the shifted signal lands on", which is the bar after the
// signal bar. That is the whole point.
SetTradeDelays( 1, 1, 1, 1 );
BuyPrice = Open;
SellPrice = Open;
// Chart section. Applying this same formula to a chart pane draws the rule and
// the bars it fires on, which is how you check the logic by eye before you let
// a report tell you anything. Note that the arrows sit on the SIGNAL bars, not
// on the fill bars - the delay lives inside the backtester, not in the arrays.
_SECTION_BEGIN( "Minimal system" );
Plot( Close, "Close", colorDefault, styleCandle );
Plot( TrendLine, "MA(" + MaPeriod + ")", colorBlue, styleLine | styleThick );
PlotShapes( shapeUpArrow * Buy, colorGreen, 0, Low, -20 );
PlotShapes( shapeDownArrow * Sell, colorRed, 0, High, 20 );
_SECTION_END();

Download minimal-system.afl65 lines

Four sections, in the order they have to happen.

The rule section computes one trend line and two events from it. Cross() gives 1 only on the bar the relationship changed, which makes Buy and Sell events rather than states — the distinction the previous lesson spent a table on.

The timing section is the three lines this lesson exists for. SetTradeDelays( 1, 1, 1, 1 ) shifts all four signal arrays by one bar inside the engine; BuyPrice and SellPrice are set to Open, and because they are not shifted, they resolve to the open of the bar the shifted signal landed on.

Short and Cover are left unassigned, which is what makes this long-only. There is a comment saying so, because an unassigned variable is invisible and a reader cannot tell an omission from a decision.

The chart section plots the rule and the signal bars. This is not decoration: being able to apply the same file to a chart and see the arrows is how you check the logic against something visible before you let a report talk to you.

  • Cross( ARRAY1, ARRAY2 ) — returns 1 on the bar ARRAY1 crosses above ARRAY2, 0 otherwise. For the downward cross, swap the arguments; there is no separate function.
  • SetTradeDelays( buydelay, selldelay, shortdelay, coverdelay ) — all four arguments required, applied inside the backtester as Ref( Buy, -buydelay ) and so on.
  • Ref( array, period ) — the value from period bars away. Negative looks back, positive looks forward. A positive shift on anything the rule uses is look-ahead bias.
  • PlotShapes( shape, color, layer, yposition, offset ) — draws arrows. Multiplying a shape constant by a Boolean array plots it only on the bars where the array is true, because shape of zero plots nothing.

Applied to a chart pane, a candlestick chart with a blue moving average and alternating green and red arrows at the crossings. Applied in the Analysis window with Apply to set to Current symbol, Range set to All quotations and Periodicity Daily, pressing Backtest produces a trade list in which:

  • entries alternate strictly with exits — never two entries in a row;
  • every trade’s entry date is one bar after a green arrow on the chart;
  • every entry price equals that bar’s opening price, to the cent.

Run signal-fill-audit.afl on the same symbol and the same range:

Complete runnable AFL

signal-fill-audit.afl
// signal-fill-audit.afl
// Part 27 - From Rules to AFL
//
// An Exploration that shows, side by side, the bar a signal was generated on
// and the bar the backtester will actually trade on when SetTradeDelays( 1, ... )
// is in force. Run it on the same symbol and the same range as your backtest and
// reconcile a handful of rows against the trade list by hand.
//
// WHY THIS EXISTS
// Trade delays are applied inside the backtester, after your formula has
// finished. Nothing on your chart moves, and nothing in the Buy array moves.
// So the only way to see the delay is to reproduce it yourself and compare.
// That is exactly what the FillDate/FillPrice columns below do: they apply
// Ref( ..., -1 ) - the documented mechanism of SetTradeDelays - and show you
// the result next to the untouched signal.
//
// ASSUMPTIONS
// Interval Daily bars, split- and dividend-adjusted.
// Delay One bar, matching SetTradeDelays( 1, 1, 1, 1 ).
// Fill price The opening print of the bar after the signal bar.
// Costs Not modelled here. This page audits timing, nothing else.
//
// How to run it:
// Formula Editor -> paste -> Tools -> Send to Analysis
// Apply to: Current symbol
// Range: All quotations, or the same range as the backtest you are checking
// Then press Explore.
MaPeriod = 100;
Delay = 1; // must match the first argument of SetTradeDelays in the system
TrendLine = MA( Close, MaPeriod );
SignalBuy = Cross( Close, TrendLine );
SignalSell = Cross( TrendLine, Close );
// What the backtester does to the signal arrays, reproduced in the open so it
// can be inspected: a signal on bar t is acted upon on bar t + Delay.
DelayedBuy = Ref( SignalBuy, -Delay );
DelayedSell = Ref( SignalSell, -Delay );
// On a bar where a delayed signal lands, these two answer "what did the rule
// see?" and "what would we have paid?". They are different bars on purpose.
SignalBarClose = Ref( Close, -Delay );
SignalBarDate = Ref( DateTime(), -Delay );
Filter = DelayedBuy OR DelayedSell;
// Two 1/0 columns rather than one text column: AddTextColumn is documented as
// taking a single string for the whole exploration, so it cannot label rows
// that differ bar by bar.
AddColumn( DelayedBuy, "Buy fills here", 1.0 );
AddColumn( DelayedSell, "Sell fills here", 1.0 );
AddColumn( SignalBarDate, "Signal bar", formatDateTimeISO );
AddColumn( SignalBarClose, "Signal bar close", 1.2 );
AddColumn( DateTime(), "Fill bar", formatDateTimeISO );
AddColumn( Open, "Fill bar open", 1.2 );
// The gap between what the rule saw and what you would have paid. A large,
// systematically negative number for buys is the overnight gap risk that a
// signal-on-close, fill-on-next-open system carries and a fill-at-close
// backtest quietly pretends away.
AddColumn( 100 * ( Open - SignalBarClose ) / SignalBarClose, "Gap %", 1.2 );

Download signal-fill-audit.afl63 lines

It reproduces the delay in the open, using the same Ref( ..., -1 ) the engine uses internally, and prints the signal bar’s date and close next to the fill bar’s date and open. Take three rows at random and find the corresponding trades in the backtest trade list. If the dates and prices match, your delay is doing what you think. If the trade list shows entries on the signal date, your formula is not applying the delay — check that SetTradeDelays() is actually in the file and has not been shadowed by a Settings value.

The Gap % column is worth a minute of attention on its own. It is the overnight move between the price your rule saw and the price you would have paid, and its average over many signals is a cost that no commission setting captures.

Symptom Cause
Entries on the same bar as the arrow No delay in force. Trading a close computed from that close.
Every trade lasts exactly one bar Sell is true on the same bars as Buy — usually a state condition where an event was intended.
Far more signals than trades Redundant entries, removed by the default mode. Apply ExRem() to make the chart agree with the report.
Entry prices are not the bar’s open BuyPrice never assigned, so the Settings trade price is in force.
An entry price does not match the printed open Price-bound checking clamped it into the high-low range.
No short trades despite a Short rule Cover never assigned; both are needed.

Change one thing and watch what happens: set the delays to SetTradeDelays( 0, 0, 0, 0 ), leave BuyPrice = Open, and run the same test. Every trade now fills at the open of the bar whose close produced the signal — a price that printed hours before the rule could have been evaluated. Compare the two trade lists side by side. The difference between them, on any reasonable data, is the size of the error people make without noticing.

Buy, Sell, Short and Cover are arrays you assign, not functions. Sell closes a long and does not open a short; a long-only system is one where Short and Cover are simply never assigned. BuyPrice and its three siblings set the fill price and are pre-filled from the Settings dialog, so a formula that does not assign them inherits a decision you did not make. AmiBroker clamps those prices into each bar’s high-low range.

SetTradeDelays( buydelay, selldelay, shortdelay, coverdelay ) shifts the four signal arrays by applying Ref( array, -delay ) inside the engine after your formula has run, and shifts nothing else — not the price arrays, not the position size, not the score. Signal on close and trade on the next open is three lines, and it is the only end-of-day configuration in which every input to the decision precedes every consequence of it.

Next: everything the fill price does not tell you — slippage, commission, liquidity limits and gaps through levels — assembled into an assumptions block you attach to every system you build from here on.

Check your understanding

Question 1. A formula contains SetTradeDelays( 1, 1, 1, 1 ) and BuyPrice = Open. On which bar does a Buy signal fire, and at which price is it filled?
SetTradeDelays( 1, 1, 1, 1 );
Buy      = Cross( Close, MA( Close, 50 ) );
BuyPrice = Open;
Show the answer and why

Answer: Fires on the signal bar; fills on the next bar, at the next bar’s open

The signal is computed on the signal bar. The delay shifts the Buy array forward by one bar inside the engine, so the trade is executed on the following bar. BuyPrice is never shifted, so it supplies that following bar’s open. This is exactly why the price arrays being unshifted matters.

Question 2. Which of these are shifted by SetTradeDelays()? Select all that apply.
Show the answer and why

Answer: Buy, Cover

The AFL Function Reference states that trade delays apply Ref() to Buy, Sell, Short and Cover only, and that no other variables are affected. If your position sizing or ranking reads from the signal arrays and you use delays, you must shift those yourself.

Question 3. Why is a zero-delay system that computes its signal from Close and fills at Close indefensible?
Show the answer and why

Answer: Because the signal needs the closing price, which is known only once trading has ended, so the order could not have been placed

It is a circularity, not an approximation. The information the rule needs arrives at the instant acting on it becomes impossible. It flatters results systematically, because signals fire on bars where price moved, and the simulated fill captures that move for free.

Question 4. A backtest produces twelve trades but the chart shows forty-one buy arrows. What is happening?
Show the answer and why

Answer: The default backtest mode removed redundant entry signals that occurred before the matching exit

In backtestRegular — the default — entry signals occurring after an entry and before the matching exit are removed in the same way ExRem() removes them. Applying ExRem() in your own formula makes the chart show what the report is built from, without changing the trades in that mode.

Sources for this lesson

6 verified · checked 2026-08-31

  1. 01AmiBroker User's Guide — Back-testing your trading ideas§ Reserved variable names; Controlling trade priceamibroker.com/guide/h_backtest.html2026-08-31
  2. 02AFL Function Reference — SetTradeDelaysamibroker.com/guide/afl/settradedelays.html2026-08-31
  3. 03AFL Function Reference — Refamibroker.com/guide/afl/ref.html2026-08-31
  4. 04AFL Function Reference — Crossamibroker.com/guide/afl/cross.html2026-08-31
  5. 05AmiBroker User's Guide — Portfolio-level backtestingamibroker.com/guide/h_portfolio.html2026-08-31
  6. 06AFL Function Reference — SetOptionamibroker.com/guide/afl/setoption.html2026-08-31

Every technical claim on this page was checked against the official AmiBroker documentation on the date shown. Where the course disagrees with folklore, the source is how you can tell which one to trust.