Skip to content
Level 4 · Trading System ResearcherLessonPart 28 · page 5 of 832 min
32Minutes
12AFL functions
6Sources
StandardRequires
AFL functions taught here12

Portfolio Backtesting: Many Symbols, One Account

Everything you have tested so far assumed one symbol and unlimited money. Portfolio backtesting removes both assumptions at once, and what it exposes is that a trading system is not just a set of rules — it is a set of rules plus a way of choosing between them when you cannot take them all.

That second half is invisible in a single-symbol test, because a single-symbol test never has to choose.

Two things to set, and one that catches everyone

Section titled “Two things to set, and one that catches everyone”

The User’s Guide says there are “only two things that need to be done” to run a portfolio backtest. Both are in the formula:

Fragment — not a complete formula

PosQty = 10;
SetOption( "MaxOpenPositions", PosQty );
SetPositionSize( 100 / PosQty, spsPercentOfEquity );

MaxOpenPositions caps how many positions can be open at once.

A position size below 100% is what actually allows more than one. This is the trap: the documentation flags it as IMPORTANT, and it is worth restating plainly — if each position is sized at 100% of equity, the account can only ever hold one, no matter what MaxOpenPositions says. The two settings have to agree, and dividing 100 by the slot count is the direct way to make them agree.

The third thing you should set, even though the guide does not force you to, is RoundLotSize = 1 for whole shares. Fractional-share fills are a real feature at some brokers and an unrealistic default at most.

One bar of a portfolio backtest

  1. Phase one: your formula runs, once per symbolBuy, Sell, Short, Cover, the four price arrays, PositionSize and PositionScore are collected for every symbol in the universe.
  2. Per-symbol conflicts resolvedSame-bar entry/exit conflicts on a single symbol are settled first, by AllowSameBarExit and HoldMinBars. Only the survivors go forward.
  3. Exits and cash releasedPositions being closed are closed. Whether the freed cash is available this bar depends on ActivateStopsImmediately for stops.
  4. Entry candidates rankedEvery symbol with a live entry signal is sorted by |PositionScore|. This is the step that does not exist in a single-symbol test.
  5. Taken in rank order until something runs outSlots, cash, MinPosValue, MinShares, MaxOpenLong/Short. The rest are refused and named in the Detailed log.
  6. Open positions marked to marketEquity is recomputed. That new equity is what the next bar's percent-of-equity sizing will use.

The ranking step is the whole subject of this lesson.

You can use the new PositionScore variable to decide which trades should be entered if there are more entry signals on different securities than the maximum allowable number of open positions or available funds. In such a case, AmiBroker will use the absolute value of PositionScore to decide which trades are preferred.

Three consequences follow, and all three bite people.

Fragment — not a complete formula

// USELESS as a rank: every candidate scores 1, so the engine has nothing to sort.
PositionScore = Buy;

A Boolean score gives the engine no information. Whatever gets picked in that case is an artefact of ordering, not a decision you made.

This is the trap in a long-and-short system. If your long candidates score 0 to 100 and your short candidates score −1 to 0, then |score| puts every long above every short, and your short limit never binds because no short ever gets near the front of the queue.

Two documented fixes, and they solve different halves of the problem:

Fragment — not a complete formula

// Build both sides from the same signed quantity, so magnitudes are comparable.
PositionScore = IIf( Buy, Quality, IIf( Short, -Quality, 0 ) );
// And/or rank the two sides separately and interleave the lists.
SetOption( "SeparateLongShortRank", True );

SeparateLongShortRank is documented as producing two separate ranking lists that are interleaved into the final signal list in the second phase of the backtest, and as being intended for use with MaxOpenLong and MaxOpenShort.

PositionScore = MA( Close * Volume, 50 ) prefers the most liquid candidate. PositionScore = 100 - RSI() — the User’s Guide’s own example — prefers the lowest RSI. PositionScore = 1 / ( 1 + stretch ) prefers the candidate closest to its own average.

None of these is the right answer, and each will select a materially different set of trades from the same signal set. The score is a second strategy sitting on top of your first one, and it deserves the same scrutiny — including the same out-of-sample testing.

Fragment — not a complete formula

SetOption( "MaxOpenLong", 6 );
SetOption( "MaxOpenShort", 4 );

Zero — the default for both — means no per-side limit. The documented interaction with MaxOpenPositions is worth having exactly right:

  • MaxOpenLong + MaxOpenShort may be greater or smaller than MaxOpenPositions.
  • If their sum is greater, MaxOpenPositions still caps the total. Set 7 long, 7 short and 10 total and you can hold at most 10, of which at most 7 are on either side.
  • If their sum is smaller (but non-zero), you cannot open more than the sum, regardless of MaxOpenPositions.
  • They cap the number of open positions of a given type. They do not, by themselves, make the ranking fair between the sides — which is why the documentation says to use them with SeparateLongShortRank.

A handful of settings decide what “you can afford this” means. All of them are documented under SetOption.

Setting What it controls
InitialEquity Starting capital for the whole portfolio
AccountMargin Margin requirement as a percentage; 100 means no margin
AllowPositionShrinking Whether a position too large for the remaining cash is reduced rather than skipped
MinPosValue The smallest position value worth entering
MinShares The smallest share count worth entering
InterestRate Interest credited on uninvested cash
UsePrevBarEquityForPosSizing Whether percent-of-equity sizing uses the previous bar’s equity

This is the single most confusing behaviour in portfolio backtesting, and it explains the question everybody eventually asks: why does my system take 120 trades when the scan found 900 signals?

In backtestRegular — the default — redundant entry signals between an entry and its matching exit are stripped exactly the way ExRem() strips them. That much is expected.

The consequence is not. If a trade is not entered on the first entry signal — it ranked too low, or the cash was gone, or the slots were full — then every later entry signal in that block is ignored too, until a matching exit signal arrives. The symbol effectively goes dormant.

Fragment — not a complete formula

// The documented alternative: keep every entry signal, and act on any that is
// ranked highly enough and affordable. Still one open position per symbol.
SetBacktestMode( backtestRegularRaw );

Complete runnable AFL

portfolio-core.afl
// portfolio-core.afl
// Part 28 - Portfolio Backtesting: Many Symbols, One Account
//
// A two-sided system whose only interesting content is the competition for
// capital: more entry signals than slots, on both sides, ranked by a score you
// can inspect. Run it as a BACKTEST with "Apply to" set to a watch list, and
// run it at least once with the result list set to "Detailed log" so you can
// watch the selection actually happen.
//
// ASSUMPTIONS
// Fill price Next bar's open. No slippage or spread here - cost-model.afl
// is where those live. Set commissions in Settings on purpose.
// Delays One bar on all four signals.
// Shorting Assumed possible, borrowable and free. None of those three is
// true in general, and none is modelled. Treat the short side of
// this formula as a study of ranking, not as a strategy.
// Sizing Equal weight across the available slots, sized from portfolio
// equity at the moment of entry.
// Liquidity Not modelled beyond the entry filter.
//
// One portfolio, one cash balance, one equity curve. That is the whole
// difference between this and a single-symbol test.
// -------------------------------------------------------- 1. the constraints
SetOption( "InitialEquity", 100000 );
SetOption( "AllowPositionShrinking", True );
SetOption( "MinPosValue", 1000 );
SetOption( "AccountMargin", 100 ); // 100 = no margin at all
RoundLotSize = 1;
PosQty = 10;
SetOption( "MaxOpenPositions", PosQty );
// Cap each side independently. Zero, the default, means no per-side limit.
// These caps do NOT change the ranking - they only refuse the surplus.
SetOption( "MaxOpenLong", 6 );
SetOption( "MaxOpenShort", 4 );
// Equal weight. Without a PositionSize that is less than 100%, the account can
// hold exactly one position no matter what MaxOpenPositions says.
SetPositionSize( 100 / PosQty, spsPercentOfEquity );
SetTradeDelays( 1, 1, 1, 1 );
BuyPrice = Open;
SellPrice = Open;
ShortPrice = Open;
CoverPrice = Open;
// --------------------------------------------------------- 2. the entry gate
// A liquidity floor applied to the SIGNAL bar, so it uses only information that
// existed before the order. Symbols that fail it generate no signal at all.
MinTurnover = 2000000;
MinClose = 2;
Tradeable = MA( Close * Volume, 50 ) >= MinTurnover
AND Close >= MinClose;
// ------------------------------------------------------------ 3. the signals
FastMa = MA( Close, 20 );
SlowMa = MA( Close, 100 );
Buy = Cross( FastMa, SlowMa ) AND Tradeable;
Sell = Cross( SlowMa, FastMa );
Short = Cross( SlowMa, FastMa ) AND Tradeable;
Cover = Cross( FastMa, SlowMa );
// When both a Buy and a Short are true on the same bar for the same symbol and
// we are flat, the documented tie-break is that Buy wins. That is a rule of the
// engine, not a judgement about the market.
// ------------------------------------------------------------- 4. the score
// PositionScore decides which candidates are taken when the signals outnumber
// the slots or the cash. It must be a MAGNITUDE, not a flag: a Boolean score
// gives the engine nothing to sort on.
//
// The preference expressed here is "take the candidate that has travelled least
// far from its slow average", measured in units of that symbol's own volatility.
// Dividing by ATR is what makes a $12 stock and a $900 stock comparable at all.
// Quality falls between 0 and 1 and is larger for the less extended candidate;
// the sign then says which side of the book the candidate belongs to.
//
// This is a choice, not a discovery. Preferring the most extended candidate
// instead is an equally coherent system, and it will select different trades.
Stretch = abs( SafeDivide( Close - SlowMa, ATR( 20 ), 0 ) );
Quality = SafeDivide( 1, 1 + Stretch, 0 );
PositionScore = IIf( Buy, Quality, IIf( Short, -Quality, 0 ) );
// THE TRAP THIS AVOIDS. By default AmiBroker ranks on the ABSOLUTE value of
// PositionScore. If long candidates scored 0 to 100 and short candidates scored
// -1 to 0, every long would outrank every short and MaxOpenShort would never
// bind. Scores built from the same signed quantity, as above, are already
// comparable - but the explicit fix, and the right one for a market-neutral
// system, is to rank the two sides separately.
SetOption( "SeparateLongShortRank", True );
// ------------------------------------------------------------ 5. the mode
// The default. Redundant entry signals - the ones between an entry and its
// matching exit - are stripped exactly the way ExRem() strips them. The
// consequence worth knowing: if a trade is skipped because it ranked too low or
// the cash was gone, every later entry signal in that block is skipped too,
// until an exit signal arrives. That is the usual reason a system appears to
// take a fraction of its own signals.
//
// Change this to backtestRegularRaw to act on any entry signal that is ranked
// highly enough and affordable, still at one open position per symbol.
SetBacktestMode( backtestRegular );
// ------------------------------------------------------ 6. what to check
// In the Detailed log, find a bar where more entry signals appeared than slots
// were free. Confirm three things: the entries taken are the top-ranked ones,
// the rejected ones are named with a reason, and the position value of each new
// trade is one PosQty-th of the equity shown on that bar. If any of the three
// does not hold, your reading of the settings is wrong somewhere.

Download portfolio-core.afl116 lines

Its only interesting content is the competition: a two-sided system that deliberately produces more entry signals than slots, ranked by a score you can inspect.

Read section 4 of the file closely. The score is built as a magnitude between 0 and 1 — “how close is this candidate to its own slow average, in units of its own volatility” — and then signed to indicate the side. Dividing by ATR is what makes a low-priced and a high-priced symbol comparable at all; without it, the score is dominated by price level rather than by the property you meant to rank on.

And the file says out loud that this is a choice: preferring the most extended candidate instead is an equally coherent system, and it will select different trades.

There is a second, quite different portfolio mode. EnableRotationalTrading() switches the backtester to score-and-rank mode — also called fund-switching — where you do not write Buy/Sell rules at all. Instead you supply PositionScore for every symbol on every bar, and the engine holds the top-ranked N, rotating as the ranks change. SetOption( "WorstRankHeld", n ) adds hysteresis by letting a position stay until its rank falls below n.

That is the whole mention it gets here, deliberately. Rotational trading is a genuinely different design with its own failure modes — it is always fully invested, it turns over on rank changes rather than on price events, and its results are extremely sensitive to how the score is constructed. Part 13’s ranking work is the groundwork; this part stays with signal-based systems so that one mechanism is understood properly rather than two half-way.

A portfolio backtest adds one thing to a single-symbol test, and that one thing changes everything: candidates compete. MaxOpenPositions caps the slots, and a position size below 100% is what makes more than one slot usable at all. PositionScore decides who gets them, ranked on its absolute value, which is why a long-and-short system needs either a symmetric signed score or SeparateLongShortRank. MaxOpenLong/MaxOpenShort cap each side, interacting with MaxOpenPositions in a documented way. And in the default mode a skipped entry silences that symbol until an exit arrives, which is the usual explanation for a system that appears to take a fraction of its own signals.

Check your understanding

Question 1. A formula sets MaxOpenPositions to 10 but the backtest never holds more than one position. What is the documented cause?
SetOption( "MaxOpenPositions", 10 );
SetPositionSize( 100, spsPercentOfEquity );
Show the answer and why

Answer: Each position is sized at 100% of equity, so there is never cash for a second one

The User's Guide flags this as IMPORTANT: to enable more than one symbol to be traded you must use a position size below 100%. 100 / PosQty is the direct way to make the two settings agree.

Question 2. A long-and-short system scores longs 0 to 100 and shorts -1 to 0. What happens, and what fixes it? Select all that apply.
Show the answer and why

Answer: Ranking uses the absolute value of PositionScore, so every long outranks every short, MaxOpenShort will effectively never bind because shorts never reach the front of the queue, Building both sides from the same signed quantity makes the magnitudes comparable, SetOption( "SeparateLongShortRank", True ) produces two ranking lists that are interleaved into the final signal list

All four are correct. The absolute-value ranking is documented, and SeparateLongShortRank is documented as being intended for use together with MaxOpenLong and MaxOpenShort for exactly this reason.

Question 3. A Scan reports 900 entry signals; the portfolio backtest takes 120 trades. Which explanation is documented behaviour?
Show the answer and why

Answer: In backtestRegular, redundant entries are stripped — and if a trade is not entered on the first signal of a block, every later signal in that block is ignored until an exit arrives

This is the skipped-signal cascade. backtestRegularRaw is the documented alternative: it keeps every entry signal and acts on any that is ranked highly enough and affordable, while still allowing only one open position per symbol.

Question 4. MaxOpenPositions is 10, MaxOpenLong is 7 and MaxOpenShort is 7. What is the maximum number of positions the account can hold?
Show the answer and why

Answer: 10, of which at most 7 on either side

When MaxOpenLong + MaxOpenShort exceeds MaxOpenPositions, the total cap still applies. When their sum is smaller but non-zero, the sum becomes the effective limit instead.

Question 5. Which statements about PositionScore are accurate? Select all that apply.
Show the answer and why

Answer: A Boolean score gives the engine nothing to sort on, Ranking by liquidity systematically prefers the largest names in the universe, which is a bias that must be stated, The score is effectively a second strategy and deserves out-of-sample testing of its own, The engine ignores PositionScore when there are fewer signals than free slots

All four hold. The last is worth noticing: the score only matters when the constraint binds, which is why a universe or a period in which signals never outnumber slots cannot tell you anything about whether your selection rule is any good.

Sources for this lesson

6 verified · checked 2026-08-31

  1. 01AmiBroker User's Guide — Portfolio-level backtestingamibroker.com/guide/h_portfolio.html2026-08-31
  2. 02AFL Function Reference — SetOption§ MaxOpenLong, MaxOpenShort, SeparateLongShortRankamibroker.com/guide/afl/setoption.html2026-08-31
  3. 03AFL Function Reference — SetPositionSizeamibroker.com/guide/afl/setpositionsize.html2026-08-31
  4. 04AFL Function Reference — SetBacktestModeamibroker.com/guide/afl/setbacktestmode.html2026-08-31
  5. 05AFL Function Reference — EnableRotationalTradingamibroker.com/guide/afl/enablerotationaltrading.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.