Skip to content
Level 3 · AFL DeveloperLessonPart 36 · page 6 of 632 min
32Minutes
9AFL functions
6Sources
StandardRequires
AFL functions taught here9

Introduction to the Custom Backtester Interface

Ordinary AFL talks to the backtester through arrays: you assign Buy, PositionSize, PositionScore, and the engine does the rest. Those arrays are computed before the backtest runs, which means they cannot depend on anything the backtest discovers while running.

The Custom Backtester Interface removes that limitation. It gives you an object-oriented view of the backtest’s second phase, where the engine walks bar by bar deciding which trades to take — and lets your code participate in that walk.

This lesson tells you what it is for, shows the smallest useful example, and is honest about where this course stops.

The Portfolio Backtester Interface Reference lists the applications directly:

  • Position sizing based on portfolio-level equity. Ordinary PositionSize is an array computed before the run; it cannot know what the equity curve is doing.
  • Advanced rotational systems, with access to ranking arrays and the ability to decide what to take after knowing which symbols score best on a bar-by-bar basis.
  • Custom metrics added to the backtest report and to optimisation result lists.
  • Custom slippage formulas.
  • Advanced scaling in and out based on portfolio equity and run-time statistics.
  • Systems that use portfolio-level statistics evaluated bar by bar to decide which trades to take.

The thread running through all of them: decisions that depend on state the backtest generates as it runs. If your rule can be expressed as an array computed from price and volume, ordinary AFL is simpler, faster and easier to check. If it genuinely needs to know the current drawdown, or how many positions are open, or what the equity was three bars ago, the CBI is the only route.

The reference describes three approaches, and choosing the wrong one is the most common mistake.

The three documented approaches

  1. High-level — Backtest()One call runs the default backtest procedure unchanged. You can add custom metrics before and after. You cannot change how any individual trade is taken. Described as "the easiest".
  2. Mid-level — PreProcess() / ProcessTradeSignal() / PostProcess()Signals can be modified and open positions queried. Documented as "good for advanced position sizing".
  3. Low-level — PreProcess() / EnterTrade() / ExitTrade() / ScaleTrade() / UpdateStats() / HandleStops() / PostProcess()Full control over the entire process. The reference says: "for hard-coded programmers only".
Higher in the stack means less control and far less that can go wrong. Choose the highest level that does what you need.

The important consequence for anyone starting: at the high level you cannot get the trades wrong, because you are not the one taking them. bo.Backtest() runs AmiBroker’s own procedure exactly as it would have run anyway. Everything you add is descriptive.

At the low level, every piece of the engine’s correctness becomes your responsibility — including HandleStops(), which the reference explicitly says must not be called in the high-level and mid-level approaches, and must be called once per bar inside the trading loop in low-level mode.

Two documented steps.

1. Enable the custom procedure. Three ways:

Fragment — not a complete formula

// The procedure is in THIS file.
SetOption( "UseCustomBacktestProc", True );
// Or: the procedure is in a separate file.
SetCustomBacktestProc( "C:\\MyPath\\MyCustomBacktest.afl" );

Or tick it in Analysis → Settings → the Portfolio tab, specifying an external procedure file.

2. Get the object, in the right context:

Fragment — not a complete formula

if( Status( "action" ) == actionPortfolio )
{
bo = GetBacktesterObject();
// ... your custom backtest code
}

The reference is explicit that GetBacktesterObject() should only be called when Status( "action" ) returns actionPortfolio. There is one documented exception: when using an external procedure file you do not need the check, because external backtest procedures are called exclusively in actionPortfolio mode.

Complete runnable AFL

custom-backtest-minimal.afl
// custom-backtest-minimal.afl
// Part 36 - Introduction to the Custom Backtester Interface
//
// The smallest custom backtest procedure that does something real. It runs
// AmiBroker's own portfolio backtest completely unchanged, then adds two
// descriptive metrics that the built-in report does not contain.
//
// WHICH LEVEL THIS IS
// This is the HIGH-LEVEL approach described in the Portfolio Backtester
// Interface Reference: one call to bo.Backtest() runs the default procedure.
// It cannot change how any individual trade is taken. The mid-level and
// low-level approaches can, and they are a much larger subject that this
// course points at rather than teaches.
//
// ASSUMPTIONS - state these on every strategy formula you write
// Universe: whatever watch list the Analysis window is pointed at.
// Period: whatever range is selected. Nothing here fixes a date range.
// Interval: daily end-of-day bars.
// Fill price: next bar's open, set by SetTradeDelays plus BuyPrice/SellPrice.
// Delay: one bar between signal and fill, on entry and on exit.
// Sizing: 10% of portfolio equity per position, set below.
// Commission: set in Analysis -> Settings. This formula does not set it,
// so a run with commission left at zero is not a real result.
// Slippage: not modelled. Assume real fills are worse than these.
// Liquidity: not modelled. Position size is never capped against volume.
//
// The rules below are a plain trend filter, present only to give the
// backtester something to process. They are not a recommendation, and
// nothing this produces is an expectation about future returns.
SetCustomBacktestProc( "" ); // "" means: the custom procedure is in THIS file
// The threshold used by the second custom metric, in bars.
MinBarsHeld = 5;
if( Status( "action" ) == actionPortfolio )
{
// Only valid in the second phase of a portfolio backtest. Anywhere else
// GetBacktesterObject() hands back an empty object.
bo = GetBacktesterObject();
// Run the default portfolio backtest, unchanged, in one call.
bo.Backtest();
// Metric 1 - derived from AmiBroker's own statistics.
st = bo.GetPerformanceStats( 0 ); // 0 = all trades
NetProfitAll = st.GetValue( "NetProfit" );
TradeCount = st.GetValue( "AllQty" );
AvgBarsHeld = st.GetValue( "AllAvgBarsHeld" );
BarsExposed = TradeCount * AvgBarsHeld;
if( BarsExposed > 0 )
ProfitPerBar = NetProfitAll / BarsExposed;
else
ProfitPerBar = 0;
bo.AddCustomMetric( "Profit per bar held ($)", ProfitPerBar );
// Metric 2 - derived by walking the closed trade list.
ShortHolds = 0;
for( trade = bo.GetFirstTrade(); trade; trade = bo.GetNextTrade() )
{
if( trade.BarsInTrade < MinBarsHeld )
ShortHolds = ShortHolds + 1;
}
bo.AddCustomMetric( "Trades held under "
+ NumToStr( MinBarsHeld, 1.0 ) + " bars", ShortHolds );
}
// --- The system under test --------------------------------------------------
SetTradeDelays( 1, 1, 1, 1 );
SetPositionSize( 10, spsPercentOfEquity );
BuyPrice = Open;
SellPrice = Open;
Trend = Close > MA( Close, 200 );
Buy = Cross( Close, MA( Close, 50 ) ) AND Trend;
Sell = Cross( MA( Close, 50 ), Close );
Short = False;
Cover = False;

Download custom-backtest-minimal.afl86 lines

This is the high-level approach: bo.Backtest() runs the default procedure unchanged, and two descriptive metrics are added around it.

Metric 1 — derived from the built-in statistics

Section titled “Metric 1 — derived from the built-in statistics”

Fragment — not a complete formula

st = bo.GetPerformanceStats( 0 ); // 0 = all trades
NetProfitAll = st.GetValue( "NetProfit" );
TradeCount = st.GetValue( "AllQty" );
AvgBarsHeld = st.GetValue( "AllAvgBarsHeld" );

GetPerformanceStats( Type ) returns a Stats object, with Type documented as 0 for all trades, 1 for long-only and 2 for short-only. GetValue( "MetricName" ) then reads any built-in metric by name.

Multiplying the trade count by the average bars held gives total bars of exposure, and dividing net profit by that gives profit per bar held — a figure the standard report does not contain, and one that makes two systems with very different holding periods directly comparable.

Metric 2 — derived by walking the trade list

Section titled “Metric 2 — derived by walking the trade list”

Fragment — not a complete formula

for( trade = bo.GetFirstTrade(); trade; trade = bo.GetNextTrade() )
{
if( trade.BarsInTrade < MinBarsHeld )
ShortHolds = ShortHolds + 1;
}

GetFirstTrade() and GetNextTrade() iterate the closed trade list. The documentation notes that GetFirstTrade() must be called before GetNextTrade(), and the loop condition works because the object is falsy when the list is exhausted.

trade.BarsInTrade is a documented property of the Trade object.

Counting very short holds is a diagnostic rather than a performance figure: a trend system that produces a large number of two-bar trades is usually being chopped up by a stop that is too tight or an exit that fires on noise, and no summary statistic will tell you that.

Fragment — not a complete formula

bo.AddCustomMetric( "Profit per bar held ($)", ProfitPerBar );

The full documented signature is:

Pseudocode — not valid AFL

AddCustomMetric( Title, Value,
[LongOnlyValue], [ShortOnlyValue],
[DecPlaces = 2], [CombineMethod = 2] )

Custom metrics appear in the backtest report, the backtest summary and the optimisation result list — which is the reason they matter more than they first appear. A custom metric can be an optimisation target, so the CBI is how you optimise for something AmiBroker does not natively measure.

Be honest about the answer, because the CBI costs you debuggability and simplicity.

You need it when:

  • The decision depends on portfolio state that does not exist until the backtest runs — current equity, current drawdown, the number of open positions, realised statistics so far.
  • You want to optimise for a measure AmiBroker does not compute.
  • You are implementing a rotational or scaling design that needs to see the ranking on each bar before deciding.

You do not need it when:

  • The rule is a function of price, volume and time. That is an array, and arrays are what ordinary AFL is for.
  • You want equal-weight sizing, percent-of-equity sizing or volatility-based sizing. SetPositionSize() does all three.
  • You want to rank candidates. PositionScore does that.
  • You want a stop. ApplyStop() does that, and doing it yourself in low-level mode means reimplementing HandleStops() correctly.

This course stops here, deliberately, and it is worth saying why: the mid-level and low-level approaches are a genuinely large subject with their own object model, their own failure modes and their own debugging techniques. Covering them badly would be worse than pointing at them clearly.

The material you need is official and complete:

Two practical suggestions for working through them.

Read the object model before writing anything. Four objects — Backtester, Signal, Trade, Stats — and only the Backtester object is directly accessible from AFL. Everything else is reached through its methods. Getting that picture straight first saves a great deal of confusion.

Debug with _TRACE, not the debugger. Since GetBacktesterObject() returns an empty object outside the second phase, and the debugger runs in actionBacktest, tracing to the Log window is the practical technique. Part 36’s debugging lesson has the mechanics.

The Custom Backtester Interface gives your code access to the second phase of a portfolio backtest, where decisions can depend on state the backtest itself generates. There are three documented levels — high-level Backtest() plus metrics, mid-level signal processing, and low-level full control described as being for hard-coded programmers only. Enable it with SetOption( "UseCustomBacktestProc", True ) or SetCustomBacktestProc(), and get the object only when Status( "action" ) is actionPortfolio, or unconditionally in an external procedure file. Custom metrics reach the report, the summary and the optimisation list, which makes them optimisable — with the caveat that you, not AmiBroker, are responsible for choosing a defensible CombineMethod for walk-forward. And the right default is the highest level that does what you need.

Check your understanding

Question 1. Which approach does bo.Backtest() belong to, and what can it not do?
Show the answer and why

Answer: High-level; it runs the default backtest procedure unchanged and cannot change how any individual trade is taken

The reference describes three approaches, with Backtest() as the high-level one — "the easiest". It runs AmiBroker's own procedure exactly as it would have run anyway, so everything you add around it is descriptive rather than behaviour-changing.

Question 2. Where must GetBacktesterObject() be called, and what is the documented exception?
if( Status( "action" ) == actionPortfolio )
{
    bo = GetBacktesterObject();
}
Show the answer and why

Answer: Only when Status( "action" ) returns actionPortfolio — except in an external procedure file, which is called exclusively in that mode

Called anywhere else it returns an empty (Null) object. External custom procedure files do not need the check because they are only ever invoked in actionPortfolio. This is also why CBI code cannot be fully stepped through under the debugger, which runs in actionBacktest.

Question 3. Which situations genuinely require the CBI rather than ordinary AFL? Select all that apply.
Show the answer and why

Answer: Sizing a position from the portfolio equity at the moment of entry, Optimising for a performance measure AmiBroker does not compute, Deciding what to take after seeing which symbols score best on each bar in a rotational design

Ranking by liquidity is exactly what PositionScore is for — an array computed before the run. The other three all depend on state that does not exist until the backtest is running, or on a measure the engine does not produce.

Question 4. Why does the CombineMethod argument of AddCustomMetric matter for walk-forward analysis?
Show the answer and why

Answer: It decides how the metric is combined across out-of-sample steps — and the documentation warns that none of the available methods may be appropriate for a given custom metric

Built-in metric summaries are calculated by a method appropriate to each value; custom metrics are combined by whichever of the six methods you choose. Averaging a metric whose calculation is not linear produces a number that is not mathematically meaningful, which is why the report notes the method used.

Question 5. The reference says HandleStops() "MUST NOT be used in high-level and mid-level approaches". What does that tell you about the low-level approach?
Show the answer and why

Answer: That at the low level you take over the engine's own responsibilities — including calling HandleStops() once per bar inside the trading loop

At the low level, correctness of the whole process becomes yours. The prohibition at higher levels exists because the engine is already doing it — calling it yourself would apply stops twice. This is the concrete form of the reference's "for hard-coded programmers only".

Sources for this lesson

6 verified · checked 2026-08-31

  1. 01AmiBroker User's Guide — Portfolio Backtester Interface Referenceamibroker.com/guide/a_custombacktest.html2026-08-31
  2. 02AFL Function Reference — SetCustomBacktestProcamibroker.com/guide/afl/setcustombacktestproc.html2026-08-31
  3. 03AFL Function Reference — GetBacktesterObjectamibroker.com/guide/afl/getbacktesterobject.html2026-08-31
  4. 04AFL Function Reference — Status§ actionamibroker.com/guide/afl/status.html2026-08-31
  5. 05AmiBroker User's Guide — How to use AFL debugger§ GetBacktesterObject outside the second phaseamibroker.com/guide/h_debugger.html2026-08-31
  6. 06AmiBroker User's Guide — Custom metricsamibroker.com/guide/a_custommetrics.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.