Skip to content
Level 4 · Trading System ResearcherLessonPart 30 · page 4 of 830 min
30Minutes
16AFL functions
6Sources
StandardRequires
AFL functions taught here16

Unrealistic Fills, Costs and Liquidity

Every fault in this lesson has the same shape: the backtest awards you a price, or a size, that the market never actually offered. None of them produces an error message. All of them make the result better. And they compound — a system with three of these is not 30% too optimistic, it is describing a market that does not exist.

Fault 1: filling at a price that was not available

Section titled “Fault 1: filling at a price that was not available”

The most common and the most expensive:

Fragment — not a complete formula

// WRONG. The condition is computed FROM the close, and the fill is AT the
// close, on the same bar. You acted on a number before it existed.
SetTradeDelays( 0, 0, 0, 0 );
BuyPrice = Close;
Buy = Close > Ref( HHV( High, 50 ), -1 );

The close is the last thing that happens in a bar. A rule computed from it cannot be acted on inside it. This is look-ahead bias in its most ordinary form, and it is so common because it is the default if you never think about delays.

The same-bar open fill, which is subtler and worse

Section titled “The same-bar open fill, which is subtler and worse”

Fragment — not a complete formula

// ALSO WRONG, and harder to spot. The decision uses the close; the fill uses
// the open of the SAME bar - a price that printed hours earlier.
SetTradeDelays( 0, 0, 0, 0 );
BuyPrice = Open;

This one is worse than the close fill, because on average it hands you a positive edge before the trade even begins: you are systematically buying at the start of a bar you already know ended higher.

The audit formula later in this lesson prints exactly that quantity, as “Same-bar leak %”. On a breakout rule it is frequently the entire result.

Fragment — not a complete formula

SetTradeDelays( 1, 1, 1, 1 );
BuyPrice = Open; // the NEXT bar's open, because the signal moved

Then verify it on a real trade. Open the trade list, pick a trade, find the signal bar on the chart, and confirm the entry date is the following bar and the price is that bar’s open. Not “confirm the setting is 1” — confirm the trade.

Fault 2: limit orders that would not have filled

Section titled “Fault 2: limit orders that would not have filled”

A backtest can express “buy at the level” trivially. Reality cannot.

Fragment — not a complete formula

// A resting order below the market. Did it fill?
BuyLimit = Ref( Close, -1 ) * 0.98;
Buy = Low <= BuyLimit;
BuyPrice = BuyLimit;

The bar traded at or below your level, so the backtest fills you there. Two things it has not accounted for:

A gap through the level. If the bar opened below your limit, no resting order at that level was ever available — the market was already past it. The honest floor is Max( BuyLimit, Low ), or better, IIf( Open < BuyLimit, Open, BuyLimit ) for a buy limit, which fills at the open when the market gapped through.

Queue position. Price touching your level does not mean your order filled. If the level was traded once and then the market moved away, the fills went to orders that were already in the queue. A backtest has no concept of the queue at all.

This is the same problem on the exit side, and it is the one that destroys risk models.

ApplyStop’s fourth argument, ExitAtStop, decides it:

Value What the backtest does on a bar that gapped through your stop
1 Fills you at the stop level — a price the market never offered
2 Recognises the trigger, exits on the next bar at the trade price
0 Only checks the trade price, so may miss the trigger entirely

The stops lesson covers this in full, including the exploration that measures how often it happens on your own data. The reason it belongs here too is what it does to risk: a system whose maximum loss per trade is enforced by ExitAtStop = 1 has no maximum loss at all in reality. Every gap-down is unbounded, and the backtest has hidden every one of them.

The quoted price is not one price. You buy at the ask and sell at the bid, so you cross half the spread on entry and half on exit — before any commission and before any market impact.

On a liquid large-cap this is small. On the instruments where backtests look most exciting — thin names with large percentage moves — the spread can be several percent, and a system whose average winner is 3% is not a system at all.

Spread belongs in the price arrays, not the commission, for the reasons in the costs lesson.

Fault 5: positions larger than what traded

Section titled “Fault 5: positions larger than what traded”

This is the one people find hardest to believe until they measure it.

A £100,000 account with ten equal slots wants £10,000 per position. On a symbol whose average daily turnover is £120,000, that is 8% of everything that changed hands. You are not a participant in that market; you are the market.

And it gets worse as the backtest compounds. Percent-of-equity sizing means positions grow with the account, so a system that quadruples its equity is, by the end, requesting positions four times larger in the same thin names.

AmiBroker’s own setting. Settings → Portfolio has “Limit trade size as % of entry bar volume”, documented as preventing entries greater than a given percentage of the entry bar’s volume:

For example, if backtesting daily data, and today’s volume for [a] thinly traded stock is 177,000 shares, setting this to 10% will limit the maximum trade size to 17,700 shares (10% of total daily volume). This prevents ‘affecting the market’ by huge orders.

Two documented cautions come with it. Instruments without volume data — mutual funds, for example — will not be enterable at all unless you set the field to zero or tick “Disable trade size limit when bar volume is zero”. And it works on the entry bar’s volume, a single bar, which is noisy; an average-turnover constraint in the formula is steadier.

A constraint in the formula, which is what the next lesson’s participation-capped sizing does, and which has the advantage of being visible in the file rather than in a dialog.

Two formulas. The first makes every execution assumption a parameter so you can see what each one is worth; the second audits individual signals.

Complete runnable AFL

cost-sensitivity-backtest.afl
// cost-sensitivity-backtest.afl
// Part 30 - Unrealistic Fills, Costs and Liquidity
//
// PURPOSE
// A deliberately ordinary long-only breakout system whose only interesting
// feature is that every execution assumption is a named parameter. Run it
// once with the costs at zero and once with them at a pessimistic setting,
// and read the difference. The difference is the part of the result that was
// never yours.
//
// ============================ ASSUMPTIONS =============================
// Universe whatever the Analysis window is applied to, with whatever
// survivorship problems that list carries. State it in your
// research log; the formula cannot know it.
// Periodicity Daily.
// Decision bar the bar on which the breakout condition becomes true,
// evaluated on that bar's CLOSE.
// Fill the NEXT bar's Open, because SetTradeDelays is 1 on all
// four signals and BuyPrice/SellPrice are built from Open.
// Slippage SlippagePct is added to every entry price and subtracted
// from every exit price, in percent. This is a stand-in for
// half-spread plus market impact. It is a guess. Make it a
// pessimistic guess.
// Commission percent of trade value, both legs, via CommissionMode 1.
// Liquidity a symbol is only tradable on a signal bar if its 50-bar
// average turnover, measured up to and including that bar,
// clears MinTurnover, and its close clears MinPrice.
// Participation NOT modelled here. Set "Limit trade size as % of entry bar
// volume" on the Settings -> Portfolio tab, or use the
// companion formula participation-capped-sizing.afl.
// Stops max-loss stop with ExitAtStop = 2: the High-Low range is
// checked, and the exit happens on the NEXT bar at the
// regular trade price. That models "the stop was breached, I
// found out, I got out at the next open" - not "I was filled
// at exactly my stop price". Confirm the actual exit prices
// in the trade list before you trust them.
// Interest set the annual interest rate to 0 in Settings unless you
// really do intend to credit idle cash.
// ======================================================================
//
// This formula produces a simulation of a rule over history. It is not a
// forecast, and no result it produces is an expectation of future returns.
SetOption( "InitialEquity", Param( "Initial equity", 100000, 10000, 1000000, 10000 ) );
BreakoutPeriod = Param( "Breakout lookback (bars)", 50, 10, 250, 5 );
ExitPeriod = Param( "Exit lookback (bars)", 20, 5, 100, 5 );
TrendPeriod = Param( "Trend filter MA (bars)", 200, 20, 300, 10 );
StopLossPct = Param( "Max loss stop (%)", 8, 1, 30, 0.5 );
PosQty = Param( "Max open positions", 10, 1, 50, 1 );
// --- the three assumptions the lesson is about -----------------------
CommissionPct = Param( "Commission per trade (%)", 0.10, 0, 1.00, 0.01 );
SlippagePct = Param( "Slippage per fill (%)", 0.15, 0, 2.00, 0.01 );
MinTurnover = Param( "Min 50-bar turnover", 2000000, 0, 50000000, 250000 );
MinPrice = Param( "Min close price", 5, 0, 100, 1 );
// ---------------------------------------------------------------------
// 1. Portfolio and cost settings, set from the formula so that the report
// and the formula can never disagree about what was assumed.
// ---------------------------------------------------------------------
SetOption( "MaxOpenPositions", PosQty );
SetOption( "AllowPositionShrinking", True );
SetOption( "CommissionMode", 1 ); // 1 = percent of trade value
SetOption( "CommissionAmount", CommissionPct );
SetOption( "ActivateStopsImmediately", True ); // entries are on the open
PositionSize = -100 / PosQty; // equal weight across open slots
// ---------------------------------------------------------------------
// 2. Execution model. Delays of 1 bar mean the backtester internally shifts
// Buy/Sell/Short/Cover by one bar, so a signal decided on today's close is
// acted on tomorrow. The price arrays are NOT shifted by the delay setting,
// which is why they are written in terms of the fill bar's own Open.
// ---------------------------------------------------------------------
SetTradeDelays( 1, 1, 1, 1 );
BuyPrice = Open * ( 1 + SlippagePct / 100 );
SellPrice = Open * ( 1 - SlippagePct / 100 );
ShortPrice = Open * ( 1 - SlippagePct / 100 );
CoverPrice = Open * ( 1 + SlippagePct / 100 );
// AmiBroker clamps any assigned trade price into the bar's High-Low range.
// A slipped open that lands outside the bar therefore fills at High or Low
// rather than at the impossible price - which is the behaviour we want.
// ---------------------------------------------------------------------
// 3. The rule itself. Every input is known at the close of the decision bar:
// the breakout level uses bars strictly BEFORE the current one, so a bar
// cannot break out of a range that includes its own high.
// ---------------------------------------------------------------------
Turnover = MA( Close * Volume, 50 );
Tradable = Turnover >= MinTurnover AND Close >= MinPrice AND Volume > 0;
BreakoutLevel = Ref( HHV( High, BreakoutPeriod ), -1 );
ExitLevel = Ref( LLV( Low, ExitPeriod ), -1 );
Trend = Close > MA( Close, TrendPeriod );
Buy = Close > BreakoutLevel AND Trend AND Tradable;
Sell = Close < ExitLevel;
// Rank competing entries by liquidity, so that when more symbols signal than
// there are slots, the more tradable ones are preferred. PositionScore is
// ranked on its ABSOLUTE value by default, so keep it strictly positive.
PositionScore = Turnover;
// ---------------------------------------------------------------------
// 4. Risk exit. ExitAtStop = 2 checks the High-Low range but executes on the
// NEXT bar at the regular trade price, so a gap through the stop is paid
// for at the gapped price rather than at the level you nominated.
// ---------------------------------------------------------------------
ApplyStop( stopTypeLoss, stopModePercent, StopLossPct, 2 );
// ---------------------------------------------------------------------
// 5. Chart view, so the same file can be inspected visually.
// ---------------------------------------------------------------------
_SECTION_BEGIN( "Cost sensitivity view" );
Plot( Close, "Close", colorDefault, styleCandle );
Plot( BreakoutLevel, "Breakout level", colorGreen, styleLine );
Plot( ExitLevel, "Exit level", colorRed, styleLine );
PlotShapes( Buy * shapeUpArrow, colorGreen, 0, Low );
PlotShapes( Sell * shapeDownArrow, colorRed, 0, High );
_SECTION_END();

Download cost-sensitivity-backtest.afl123 lines

An ordinary long-only breakout system whose only interesting feature is that commission, slippage, the liquidity floor and the minimum price are all named parameters.

Run it twice: once with CommissionPct and SlippagePct at zero, once at a pessimistic setting. The difference between the two results is the part of the result that was never yours.

Note the entry condition:

Fragment — not a complete formula

BreakoutLevel = Ref( HHV( High, BreakoutPeriod ), -1 );
Buy = Close > BreakoutLevel AND Trend AND Tradable;

The lookback is shifted so that a bar cannot break out of a range that includes its own high. That is not a cost issue, but it belongs in the same audit: every input to the decision must be known at the close of the decision bar.

Complete runnable AFL

signal-execution-audit.afl
// signal-execution-audit.afl
// Part 30 - Challenge Collection: Six Broken Backtests
//
// PURPOSE
// A backtest report tells you what the engine decided. It does not tell you
// whether those decisions were physically possible. This exploration prints
// one row per entry signal with the numbers you need to answer that
// question: which price each execution assumption would have used, how much
// free money a same-bar fill would have handed you, how many shares the
// sizing rule wants, and how that compares with what actually traded that
// day.
//
// Point it at the same universe and the same date range as the backtest you
// are auditing, and change the rule in section 2 to match the one you are
// checking.
//
// HOW TO RUN
// Analysis -> Apply to: the universe under audit. Periodicity: Daily.
// Range: the same range as the backtest. Press EXPLORE.
// Sort by "Participation % of bar volume" descending and look at the top.
//
// ============================ ASSUMPTIONS =============================
// Account size stated, not read from a live equity curve. The share
// counts below are what an equal-weight allocation of that
// stated account would buy. A compounding backtest will want
// larger positions than this as equity grows, so treat these
// figures as the optimistic case.
// Fill prices three of them are printed side by side so you can see what
// the choice is worth: this bar's Open, this bar's Close,
// and the next bar's Open.
// Costs the round-trip estimate is commission on both legs plus
// two slippage crossings. It is an assumption, not a
// measurement.
// Ref(Open, 1) reads the bar after the signal. That is legitimate in an
// AUDIT, which is looking backwards at what happened next.
// The identical expression inside a Buy rule is a bug.
// ======================================================================
SetBarsRequired( -2, -2 );
AccountSize = Param( "Assumed account size", 100000, 10000, 10000000, 10000 );
PosQty = Param( "Max open positions", 10, 1, 50, 1 );
CommissionPct = Param( "Commission per trade (%)", 0.10, 0, 1.00, 0.01 );
SlippagePct = Param( "Slippage per fill (%)", 0.15, 0, 2.00, 0.01 );
StopPct = Param( "Max loss stop (%)", 8, 1, 30, 0.5 );
// ---------------------------------------------------------------------
// 1. Reference measurements
// ---------------------------------------------------------------------
Turnover = MA( Close * Volume, 50 );
// ---------------------------------------------------------------------
// 2. THE RULE UNDER AUDIT. Replace this with the entry condition of whatever
// backtest you are checking. Everything below is generic.
// ---------------------------------------------------------------------
BreakoutPeriod = Param( "Breakout lookback (bars)", 50, 10, 250, 5 );
EntrySignal = Close > Ref( HHV( High, BreakoutPeriod ), -1 ) AND Volume > 0;
// ---------------------------------------------------------------------
// 3. What each execution assumption would have paid
// ---------------------------------------------------------------------
FillSameBarOpen = Open; // delay 0, trade price Open
FillSameBarClose = Close; // delay 0, trade price Close
FillNextBarOpen = Ref( Open, 1 ); // delay 1, trade price Open
// The gap between deciding on the Close and being filled at that same bar's
// Open. Positive means the leak paid you before the trade began.
SameBarEdgePct = 100 * SafeDivide( Close - Open, Open, 0 );
// What the honest assumption costs relative to the leaky one, on this bar.
NextOpenGapPct = 100 * SafeDivide( FillNextBarOpen - Close, Close, Null );
// ---------------------------------------------------------------------
// 4. Size, and whether the market could have absorbed it
// ---------------------------------------------------------------------
IntendedDollars = AccountSize / PosQty;
FillPrice = FillNextBarOpen * ( 1 + SlippagePct / 100 );
IntendedShares = SafeDivide( IntendedDollars, FillPrice, Null );
EntryBarVolume = Ref( Volume, 1 ); // the bar the fill happens on
ParticipationBar = 100 * SafeDivide( IntendedShares, EntryBarVolume, Null );
ParticipationAvg = 100 * SafeDivide( IntendedDollars, Turnover, Null );
// ---------------------------------------------------------------------
// 5. Cost and stop realism
// ---------------------------------------------------------------------
RoundTripCostPct = 2 * CommissionPct + 2 * SlippagePct;
StopLevel = FillPrice * ( 1 - StopPct / 100 );
// Did the next bar open below where the stop would have been placed? If so, a
// backtest that exits "at the stop level" is claiming a fill at a price that
// was never available on that bar.
GapThroughStop = Ref( Open, 1 ) < StopLevel;
// ---------------------------------------------------------------------
// 6. Output: one row per entry signal
// ---------------------------------------------------------------------
Filter = EntrySignal AND Status( "barinrange" );
AddColumn( DateTime(), "Signal date", formatDateTime );
AddColumn( Close, "Decision close", 1.3 );
AddColumn( FillSameBarOpen, "Fill: same-bar open", 1.3 );
AddColumn( FillSameBarClose, "Fill: same-bar close", 1.3 );
AddColumn( FillNextBarOpen, "Fill: next-bar open", 1.3 );
AddColumn( SameBarEdgePct, "Same-bar leak %", 1.2 );
AddColumn( NextOpenGapPct, "Overnight gap %", 1.2 );
AddColumn( IntendedDollars, "Intended position", 1.0 );
AddColumn( IntendedShares, "Intended shares", 1.0 );
AddColumn( EntryBarVolume, "Entry bar volume", 1.0 );
AddColumn( ParticipationBar, "Participation % of bar volume", 1.2 );
AddColumn( ParticipationAvg, "Participation % of avg turnover", 1.2 );
AddColumn( RoundTripCostPct, "Assumed round-trip cost %", 1.2 );
AddColumn( StopLevel, "Stop level", 1.3 );
AddColumn( GapThroughStop, "Gapped through stop", 1.0 );
AddColumn( Turnover, "50-bar avg turnover", 1.0 );
// COUNT and AVERAGE rows: the average same-bar leak is the single most useful
// number on this table.
AddSummaryRows( 2 | 16, 1.2 );

Download signal-execution-audit.afl124 lines

Run this as an Exploration over the same universe and range as the backtest you are checking, having replaced the rule in section 2 with your own entry condition. It prints one row per entry signal with:

  • what three different fill assumptions would each have paid
  • Same-bar leak % — how much free money a same-bar close-to-open fill would have handed you
  • the overnight gap between the decision close and the next open
  • the intended share count, and that count as a percentage of the entry bar’s volume
  • the same as a percentage of 50-bar average turnover
  • whether the next bar opened below where the stop would have sat

Run this against any backtest before you believe it.

  1. Delays. Are they at least 1 on all four signals? Confirmed on an actual trade, not just in the settings?
  2. Fill price. Is the price array read on the bar the shifted signal lands on, and is it a price that existed on that bar?
  3. Same-bar leak. Does any decision use a bar’s close and fill within that same bar?
  4. Limit fills. If you fill at a level, what happens when the bar gapped through it? Is queue position acknowledged as unmodelled?
  5. Stops. Is ExitAtStop 2 rather than 1 on daily data? If 1, have you measured gap frequency on your universe?
  6. Spread. Is it in the price arrays, in the correct direction on all four?
  7. Commission. Set, on both legs, at a figure you can point to in a broker schedule?
  8. Participation. What is the largest position, as a percentage of that bar’s volume and of average turnover? Do you know, or are you guessing?
  9. Compounding. Does the position size grow with equity, and if so, is the participation constraint growing with it?
  10. Sensitivity. At what cost level does the conclusion change?

Five faults, all silent, all favourable. Filling inside the bar that produced the decision hands you an edge before the trade starts. Limit fills assume you were at the front of a queue that is not modelled, and are wrong one-directionally. ExitAtStop = 1 removes gaps from your risk model entirely. Spread is real, belongs in the price, and is widest when your rules most want to trade. And a position larger than a meaningful fraction of what actually traded is not a position — it is a fiction that compounds as the backtest’s equity grows. Every one of these can be measured on your own data, and measuring them is cheaper than finding out later.

Check your understanding

Question 1. Why is filling at the same bar's OPEN, on a rule computed from that bar's CLOSE, worse than filling at the close?
SetTradeDelays( 0, 0, 0, 0 );
BuyPrice = Open;
Buy = Close > Ref( HHV( High, 50 ), -1 );
Show the answer and why

Answer: Because it systematically buys at the start of a bar already known to have ended higher, handing the system a positive edge before the trade begins

The close fill acts on information from the same instant. The open fill acts on information from hours later — a strictly larger violation, and one that produces a measurable average gain per trade. That quantity is what the audit prints as "Same-bar leak %".

Question 2. A limit-order system fills whenever Low <= BuyLimit. Which real-world effects are unmodelled? Select all that apply.
Show the answer and why

Answer: Bars that opened below the limit, where no resting order at that level was ever available, Queue position — price touching a level does not mean your order was the one filled, The asymmetry that you are filled when the market keeps going against you and miss the fills where it turned

Commission is modelled by the commission setting. The other three are all invisible to a backtest, and all three bias in the same direction, which is why limit systems are the most likely to be dramatically wrong.

Question 3. What does AmiBroker's "Limit trade size as % of entry bar volume" setting do, and what is the documented caution?
Show the answer and why

Answer: It prevents entries greater than a given percentage of the entry bar's volume — but instruments with zero volume, such as mutual funds, become unenterable unless the field is zero or the "disable when bar volume is zero" box is ticked

The Settings documentation gives the worked example of 10% of 177,000 shares limiting a trade to 17,700, and flags the zero-volume case explicitly. Note also that it uses a single bar's volume, which is noisier than an average-turnover constraint written into the formula.

Question 4. Why does the participation problem get worse as a backtest progresses?
Show the answer and why

Answer: Because percent-of-equity sizing grows positions with the account, so a system that quadruples its equity requests four-times-larger positions in the same instruments

The constraint that mattered at the start is four times more binding at the end, and nothing in a default backtest notices. This is why a participation cap must scale with the sizing rule rather than being checked once.

Question 5. Ref( Open, 1 ) appears in the audit exploration. Is that acceptable?
Show the answer and why

Answer: Yes, because the audit measures what happened after each signal rather than producing trading decisions. The identical expression inside a Buy rule would be a bug

The distinction is what the output is used for. Measuring history is not the same activity as acting on it. This is the same reasoning that makes forward-return studies legitimate while the same line in a signal formula invalidates everything downstream.

Sources for this lesson

6 verified · checked 2026-09-01

  1. 01AmiBroker User's Guide — Settings window§ Limit trade size as % of entry bar volumeamibroker.com/guide/w_settings.html2026-09-01
  2. 02AFL Function Reference — SetOption§ PriceBoundChecking, CommissionModeamibroker.com/guide/afl/setoption.html2026-08-31
  3. 03AFL Function Reference — ApplyStop§ ExitAtStopamibroker.com/guide/afl/applystop.html2026-08-31
  4. 04AmiBroker User's Guide — Portfolio-level backtestingamibroker.com/guide/h_portfolio.html2026-08-31
  5. 05AFL Function Reference — SetTradeDelaysamibroker.com/guide/afl/settradedelays.html2026-08-31
  6. 06AFL Function Reference — SafeDivideamibroker.com/guide/afl/safedivide.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.