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.
What the engine actually does
Section titled “What the engine actually does”One bar of a portfolio backtest
- 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.
- Per-symbol conflicts resolvedSame-bar entry/exit conflicts on a single symbol are settled first, by AllowSameBarExit and HoldMinBars. Only the survivors go forward.
- Exits and cash releasedPositions being closed are closed. Whether the freed cash is available this bar depends on ActivateStopsImmediately for stops.
- 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.
- Taken in rank order until something runs outSlots, cash, MinPosValue, MinShares, MaxOpenLong/Short. The rest are refused and named in the Detailed log.
- 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.
PositionScore: how candidates are chosen
Section titled “PositionScore: how candidates are chosen”You can use the new
PositionScorevariable 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 ofPositionScoreto decide which trades are preferred.
Three consequences follow, and all three bite people.
It must be a magnitude, not a flag
Section titled “It must be a magnitude, not a flag”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.
It ranks on the absolute value
Section titled “It ranks on the absolute value”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.
The score is a decision you are making
Section titled “The score is a decision you are making”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.
Per-side limits
Section titled “Per-side limits”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 + MaxOpenShortmay be greater or smaller thanMaxOpenPositions.- If their sum is greater,
MaxOpenPositionsstill 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.
Cash and equity accounting
Section titled “Cash and equity accounting”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 |
The skipped-signal cascade
Section titled “The skipped-signal cascade”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 );The formula
Section titled “The formula”Complete runnable 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 constraintsSetOption( "InitialEquity", 100000 );SetOption( "AllowPositionShrinking", True );SetOption( "MinPosValue", 1000 );SetOption( "AccountMargin", 100 ); // 100 = no margin at allRoundLotSize = 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 signalsFastMa = 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.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.
Rotational trading, bounded
Section titled “Rotational trading, bounded”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
Sources for this lesson
6 verified · checked 2026-08-31
- 01AmiBroker User's Guide — Portfolio-level backtestingamibroker.com/guide/h_portfolio.html2026-08-31
- 02AFL Function Reference — SetOption§ MaxOpenLong, MaxOpenShort, SeparateLongShortRankamibroker.com/guide/afl/setoption.html2026-08-31
- 03AFL Function Reference — SetPositionSizeamibroker.com/guide/afl/setpositionsize.html2026-08-31
- 04AFL Function Reference — SetBacktestModeamibroker.com/guide/afl/setbacktestmode.html2026-08-31
- 05AFL Function Reference — EnableRotationalTradingamibroker.com/guide/afl/enablerotationaltrading.html2026-08-31
- 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.