Skip to content
Level 4 · Trading System ResearcherLessonPart 34 · page 3 of 530 min
30Minutes
11AFL functions
7Sources
StandardRequires
AFL functions taught here11

Portfolio Exposure, Concentration and Correlation

Ten positions, each risking one per cent of the account, is not a ten per cent risk. It is somewhere between about three per cent and considerably more than ten, and which end you get depends on something the position-sizing arithmetic never looked at: how much the ten holdings have in common.

This lesson is about the account rather than the trade. You will learn what gross and net exposure mean and how they differ from the Exposure % in AmiBroker’s report, which portfolio constraints the backtester can actually enforce and which it cannot, how to read a correlation coefficient without over-reading it, and why the diversification you arranged in advance has a habit of failing on the specific days you arranged it for.

Two numbers describe how much market you own.

  • Gross exposure is the sum of the absolute values of all open positions, divided by equity. Six longs of 10% and four shorts of 10% is 100% gross.
  • Net exposure is longs minus shorts, divided by equity. The same book is 20% net.

For a long-only account they are the same number, which is why the distinction is easy to ignore until you add a short side. That book is 100% exposed to anything that moves every share at once and only 20% exposed to the market’s direction — a difference that matters enormously on a day when everything falls together, because “market-neutral” describes the net figure and the losses arrive through the gross one.

Neither number is what AmiBroker prints. The report’s Exposure % is a time average: the value of open positions divided by portfolio equity, computed bar by bar, summed and divided by the number of bars. A system that is fully invested a third of the time and flat the rest reports about 33%, not 100%. It describes how much of the test you spent in the market weighted by how much you had on, not how much you had on at the worst moment.

There is one more lever on gross exposure, and it is a trap worth naming. Settings → General → Account margin defaults to 100, meaning every trade is fully funded. Setting it to 50 simulates borrowing: the documented example is that initial equity of 10,000 gives buying power of 20,000. Gross exposure can then reach 200%, and so can the losses. Everything in this part assumes 100 unless it says otherwise.

SetOption( "MaxOpenPositions", n ) caps the number of simultaneously open positions, and overrides whatever the Settings dialog says. It is the single most consequential number in a portfolio backtest after the position size itself, and the two are usually chosen together:

Fragment — not a complete formula

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

Raising n spreads the account over more names, which reduces the damage any one of them can do. It also does three other things that nobody mentions: it forces the ranking rule to reach further down its list, so the marginal position is by construction a worse candidate than the first; it increases turnover and therefore costs; and it does almost nothing for you if the extra names behave like the ones you already own.

Two more limits exist. MaxOpenLong and MaxOpenShort cap each side independently; zero, the default, means no limit on that side. They interact with the global cap in a documented and slightly surprising way — if their sum exceeds MaxOpenPositions, the global cap still binds the total while the side caps still bind each side, and if their sum is smaller, you can never open more than the sum. They cap positions only; they do not change how candidates are ranked, and ranking uses the absolute value of PositionScore by default, so an asymmetric score lets one side monopolise the list. SetOption( "SeparateLongShortRank", True ) is the documented fix, producing two ranked lists that are interleaved.

A trend rule fires when trends exist. Trends in a single sector tend to exist at the same time, because the companies in it sell into the same demand, borrow at the same rates and get re-rated by the same news. So the day your rule produces fifteen signals is very often the day those fifteen signals are eleven miners and four banks.

Nothing in the sizing arithmetic notices. Each position gets its one per cent of risk, the report shows a nicely diversified count of positions, and the account holds one bet on the price of copper wearing eleven different names.

AmiBroker can tell you which sector a symbol is in: SectorID() returns the numeric sector ID and SectorID( 1 ) returns its name, with IndustryID(), GroupID(), MarketID() and the watch list functions covering the other classifications. What it does not have is a documented option that caps positions per sector. There is no SetOption( "MaxOpenPerSector", … ), and you should be suspicious of any forum snippet that implies otherwise.

What you can honestly do, in increasing order of effort:

  • Measure it after the fact. Export the trade list, group the entries by sector and by date, and look at the maximum number of simultaneous holdings in one sector. If that number is close to MaxOpenPositions, your diversification is nominal.
  • Constrain the universe instead of the book. Run the strategy on a watch list built with a limit on names per sector. This changes what the strategy is, and you must say so, but it is transparent and it requires no special machinery.
  • Push against it with the ranking rule. PositionScore decides who gets the scarce slots, so a score that penalises whatever is currently over-represented will bias selection away from it. Be clear-eyed: this is a preference, not a limit, and building it requires cross-symbol state, which means static variables and the ordering hazards Part 36 covers.
  • Enforce it in the custom backtest procedure. The mid-level interface — PreProcess(), ProcessTradeSignals(), PostProcess() — runs in the backtester’s second phase, where the open positions are enumerable and a signal can be skipped. This is the only way to impose a genuine per-sector cap. Part 36 introduces the interface; it is out of scope here beyond knowing that it exists and that it is where this problem is solved.

Correlation is a number between −1 and +1 describing how tightly two return series moved together over a chosen window. Plus one means they moved in lockstep, zero means knowing one told you nothing about the other, minus one means they moved oppositely. That is all it is. It is not causation, it is not a constant, and it is not a property of the instruments — it is a property of the instruments during the window you measured.

The reason it matters for sizing is a piece of arithmetic that has nothing to do with markets. For n equally weighted positions, each with the same volatility, and every pair correlated by the same amount p, the volatility of the whole book as a fraction of one position’s volatility is:

√( (1 + (n − 1)p) / n )

For a book of ten:

Average pairwise correlation Book volatility, as a fraction of one position Effective number of independent bets
0.0 0.32 10.0
0.2 0.53 3.6
0.4 0.68 2.2
0.6 0.80 1.6
0.8 0.91 1.2
1.0 1.00 1.0

The right-hand column is the reciprocal of the square of the middle one: the number of genuinely independent positions your ten are equivalent to. At a pairwise correlation of 0.4 — an entirely ordinary figure for large shares in one market — ten positions behave like slightly more than two.

That is not a claim about any market; it is what the formula says, and you can verify it with a calculator. What it means in practice is that the diversification benefit of adding positions falls away very quickly, and that a risk budget built by adding up per-trade risks is optimistic by a factor you can estimate.

An exploration that reports, for every symbol in a watch list, how strongly it moved with a benchmark over the last few hundred bars — and, more usefully, what it did on the days the benchmark fell hard. One row per symbol, sortable, with the answer to the question a backtest report never asks.

Complete runnable AFL

candidate-correlation-exploration.afl
// candidate-correlation-exploration.afl
// Part 34 - Portfolio Exposure, Concentration and Correlation
//
// One row per symbol, answering a question a backtest report never asks: how
// much of this candidate's behaviour is the market's behaviour, and what did it
// do on the days the market fell hard?
//
// The last two columns are the ones that matter. If a symbol's average return
// on the benchmark's worst days is close to the benchmark's own, then holding
// it alongside nine others like it is not diversification - it is one position
// wearing ten name tags.
//
// ASSUMPTIONS:
// - Daily bars, split- and dividend-adjusted.
// - Benchmark must exist in the same database, on the same calendar, with
// history covering the whole range. Change the symbol below to whatever
// index your universe actually belongs to.
// - Correlation and beta here are ordinary statistics computed by this
// formula. They are not AmiBroker backtest report metrics and they do not
// appear in any report.
// - Correlation measures co-movement over the window chosen. It says nothing
// about cause, and a number computed over calm years does not describe the
// next crisis.
//
// How to run it: Formula Editor -> Send to Analysis -> Apply to: your watch
// list -> Range: All quotations (or a fixed range) -> Explore.
Benchmark = "^GSPC"; // change to your market's index symbol
Lookback = 250; // bars in the correlation and beta windows
StressLevel = -2.0; // a benchmark day worse than this counts as stressed
MinStressN = 20; // symbols with fewer stressed days are not reported
BenchClose = Foreign( Benchmark, "C" );
SymReturn = ROC( Close, 1 );
BenchReturn = ROC( BenchClose, 1 );
// A bar is usable only when both series produced a return on it. Nz() then
// stops the Nulls from poisoning the running sums; the Usable flag keeps the
// substituted zeros out of every average.
Usable = NOT IsNull( SymReturn ) AND NOT IsNull( BenchReturn );
r1 = Nz( SymReturn );
r2 = Nz( BenchReturn );
// Correlation: -1 to +1, how tightly the two return series moved together.
CorrWithBench = Correlation( r1, r2, Lookback );
// Beta: how far this symbol tends to move for a one per cent benchmark move.
// Correlation times the ratio of the two standard deviations. The Max() guards
// a benchmark window with no variation at all.
BenchStDev = Max( StDev( r2, Lookback ), 0.0001 );
BetaVsBench = CorrWithBench * StDev( r1, Lookback ) / BenchStDev;
// What happened on the days the benchmark fell hard.
Stressed = Usable AND r2 < StressLevel;
StressN = Cum( Stressed );
StressSum = Cum( Stressed * r1 );
AllN = Cum( Usable );
AllSum = Cum( Usable * r1 );
StressMean = IIf( StressN > 0, StressSum / StressN, 0 );
AllMean = IIf( AllN > 0, AllSum / AllN, 0 );
StressGap = StressMean - AllMean;
// One row per symbol: accept only the last bar of the range, by which point
// the running totals hold the whole history.
Filter = Status( "lastbarinrange" )
AND AllN >= Lookback + 20
AND StressN >= MinStressN;
AddColumn( CorrWithBench, "Correlation", 1.2 );
AddColumn( BetaVsBench, "Beta", 1.2 );
AddColumn( AllN, "Days measured", 1.0 );
AddColumn( AllMean, "Mean % all days", 1.3 );
AddColumn( StressN, "Stressed days", 1.0 );
AddColumn( StressMean, "Mean % on those days", 1.3 );
AddColumn( StressGap, "Difference", 1.3 );
// Row 2 is the average across symbols, row 16 the count. Read the average
// carefully: every symbol counts once, however many stressed days it saw.
AddSummaryRows( 2 | 16, 1.3 );

Download candidate-correlation-exploration.afl81 lines

Foreign( Benchmark, "C" ) pulls the benchmark’s closes onto the current symbol’s bar alignment. ROC( Close, 1 ) turns both series into daily percentage returns, because correlating price levels rather than returns produces a number close to 1 for any two things that both drifted upward, which tells you nothing.

Correlation( r1, r2, Lookback ) is the standard coefficient over a rolling window. Beta — correlation times the ratio of the two standard deviations — is added because correlation alone says how reliably a symbol follows the benchmark and not how far: a symbol can track the index perfectly and move twice as much, and it is the second figure that decides your loss.

The last block is the one worth having. Stressed flags bars where the benchmark itself fell more than two per cent. Cum() accumulates the count and the sum of the symbol’s returns on those bars, so the final bar of the range holds the mean return on stressed days and the mean across all days. Filter = Status( "lastbarinrange" ) AND … reduces the whole history to one row per symbol.

Both Nz() calls and the Usable flag exist because a symbol that was not trading on a day the benchmark was produces a Null, and a Null in a running sum silently destroys the row.

  • Correlation( array1, array2, periods ) — the rolling correlation coefficient. The window is in bars, so its meaning changes with periodicity.
  • StDev( array, periods, population = True ) — the moving standard deviation. The third argument, added in 6.20, selects population or sample; the default is population.
  • Foreign( ticker, datafield, fixup = 1 ) — reads another symbol’s array. The default fixup fills missing bars from the previous close, which is what you want here and is emphatically not what you want when you are hunting for data holes.

Set the benchmark symbol to a symbol that is in your watch list. That row must report a correlation of 1.00 and a beta of 1.00 — anything else means the alignment is wrong, usually because the benchmark’s calendar differs from the symbols’ and Foreign() is padding. Then lower StressLevel from −2.0 to −4.0 and confirm the stressed-day count falls and the mean becomes more negative. If it does not, the returns are not aligned.

The two failures worth expecting: a benchmark symbol that does not exist in the database, which returns an empty series and produces an empty exploration with no error message; and a Lookback longer than the history of some symbols, which the AllN >= Lookback + 20 filter is there to exclude. As an extension, replace the single benchmark with a sector index and run it per sector — a symbol’s correlation with its own sector is usually far higher than with the broad market, and it is the more relevant number when your signals cluster by sector.

Why diversification fails exactly when you need it

Section titled “Why diversification fails exactly when you need it”

Correlations measured over years of ordinary trading are averages of two different regimes: a long stretch where instruments respond mostly to their own news, and short episodes where they respond to one thing. In those episodes, the average is not the number that applies.

The mechanisms are unglamorous and well understood.

  • Common factors dominate. On an ordinary day, a mining company’s price is about its own drilling results. On a day when funding costs jump, it is about funding costs, and so is every other price. The idiosyncratic component does not disappear; it becomes a rounding error next to the factor everything shares.
  • Selling is not selective. Investors who must raise cash sell what is liquid, not what they have decided to dislike. Index funds and multi-asset vehicles trade whole baskets. Leveraged holders facing margin calls sell whatever will clear.
  • Your positions were selected for a shared characteristic. A rule that buys strength owns what has recently risen; a rule that buys value owns what is cheap on the same measure. Selection by a common criterion produces a book with a common exposure, whatever the sector labels say.
  • Your own exits are correlated. Ten stops set at three ATRs below entry, in ten instruments that just fell together, trigger together — into the same thin market, on the same morning, alongside everyone else running a similar rule.

The consequence for sizing is direct, and it is the reason this lesson sits where it does. The risk you should plan around is not ten independent one per cent losses. It is the loss when correlation goes to something close to one and the stops fill worse than they should — which is why gross exposure and the maximum count of positions are risk parameters in their own right, not just capital-efficiency settings.

The template below is a strategy skeleton whose interesting content is the constraint block: every documented option that limits what the backtester is allowed to do, set in the formula rather than in the Settings dialog so that the constraints travel with the code and appear in the report when you include the formula in it.

Where a trade can be refused

  1. Universe filterLiquidity floor in the formulayour AFL
  2. SignalBuy is true on this baryour AFL
  3. RankingPositionScore, top 2×MaxOpenPositions kept
  4. Position countMaxOpenPositions, MaxOpenLong, MaxOpenShort
  5. SizePositionSize, MinShares, MinPosValue
  6. Cash and marginAvailable cash, AccountMargin, position shrinking
  7. Fill limitLimit trade size as % of entry bar volume

Complete runnable AFL

portfolio-constraints-template.afl
// portfolio-constraints-template.afl
// Part 34 - Portfolio Exposure, Concentration and Correlation
//
// A skeleton whose only interesting part is the constraint block. Every option
// set below is documented in the AmiBroker User's Guide, and every one of them
// changes which trades the backtester is allowed to take. The signal rules are
// deliberately dull, because the point is what surrounds them.
//
// Use this as the top of your own strategy formulas: state the constraints in
// the formula rather than in the Settings dialog, so that the constraints
// travel with the code and appear in the report when you include the formula.
//
// ASSUMPTIONS:
// - Daily bars, split- and dividend-adjusted.
// - Signals read on the close; orders filled at the next bar's open.
// - Commission 0.1% each way. Slippage is NOT modelled here.
// - Long only, so gross exposure and net exposure are the same number.
// Add a short side and they part company.
//
// NOT expressible with these options: a cap on the number of positions per
// sector, and a cap on aggregate exposure to one sector. AmiBroker has no
// documented SetOption for either. See the lesson for what you can do instead.
// ---- Capital --------------------------------------------------------------
SetOption( "InitialEquity", 100000 );
// Account margin: 100 means every trade is fully funded, which is the default
// and the only setting this course uses. A lower number simulates borrowing,
// which multiplies gross exposure and multiplies losses with it.
SetOption( "AccountMargin", 100 );
// ---- How many bets at once ------------------------------------------------
MaxPositions = 10;
SetOption( "MaxOpenPositions", MaxPositions );
// Per-side caps. Zero, the default, means no limit on that side. They cap
// positions only; they do not change how candidates are ranked.
SetOption( "MaxOpenLong", MaxPositions );
SetOption( "MaxOpenShort", 0 );
// ---- How big each bet may be ----------------------------------------------
// Equal weight across the maximum position count, so a full book is fully
// invested and no single name can exceed one tenth of equity at entry.
SetPositionSize( 100 / MaxPositions, spsPercentOfEquity );
// If the requested size is larger than the cash available: True enters a
// smaller position, False refuses the trade entirely. The choice changes the
// trade count, so record which one you used.
SetOption( "AllowPositionShrinking", True );
// Floors. A position too small to be worth entering in real life should not be
// entered in the simulation either.
SetOption( "MinShares", 1 );
SetOption( "MinPosValue", 500 );
// Size on the previous bar's closing equity rather than current intraday
// equity. Slower to react, and closer to what you could actually compute the
// evening before you place the order.
SetOption( "UsePrevBarEquityForPosSizing", True );
// ---- Execution ------------------------------------------------------------
SetOption( "CommissionMode", 1 );
SetOption( "CommissionAmount", 0.1 );
SetTradeDelays( 1, 1, 1, 1 );
BuyPrice = Open;
SellPrice = Open;
RoundLotSize = 1;
// ---- Universe floor -------------------------------------------------------
// The formula-level half of the liquidity constraint. The other half lives in
// Settings -> Portfolio -> "Limit trade size as % of entry bar volume", which
// caps the size of any single fill as a share of that bar's volume.
MinTurnover = 2000000;
Liquid = MA( Close * Volume, 50 ) > MinTurnover;
// ---- Signals (deliberately plain) -----------------------------------------
Trend = Close > MA( Close, 200 );
Buy = Cross( Close, MA( Close, 50 ) ) AND Trend AND Liquid;
Sell = Cross( MA( Close, 50 ), Close );
PositionScore = 100 - RSI( 14 );
// ---- Auditing the constraints --------------------------------------------
// Report mode 1 is the Detailed log: one row per bar, showing scores, open
// positions and the reason a candidate was or was not entered. It is the only
// way to see a constraint doing its job, and the first thing to switch on when
// a backtest takes fewer trades than you expected. Switch it back to 0 (the
// trade list) before running anything large - the log is enormous.
SetOption( "PortfolioReportMode", 1 );

Download portfolio-constraints-template.afl89 lines

Four groups are worth reading closely.

Capital. InitialEquity is the whole portfolio in a portfolio backtest and the per-symbol equity in an Individual backtest — the User’s Guide makes the distinction explicitly, and it is a common source of results that make no sense. AccountMargin at 100 means fully funded.

Count. MaxOpenPositions with the two side caps, as described above.

Size. SetPositionSize with spsPercentOfEquity, plus the three floors and switches that quietly change your trade count: AllowPositionShrinking decides whether an oversized request enters small or does not enter at all; MinShares and MinPosValue refuse trades below a threshold; and UsePrevBarEquityForPosSizing switches sizing from current intraday equity to the previous bar’s close, which is both slower to react and closer to what you could have computed the evening before.

Fills. The formula sets a turnover floor on the universe. The other half lives in Settings → Portfolio → Limit trade size as % of entry bar volume, documented with a worked example: a thinly traded stock doing 177,000 shares with the limit at 10% caps the trade at 17,700 shares. Note the documented trap — instruments with no volume data, such as many mutual funds, will take no trades at all unless the limit is zero or Disable trade size limit when bar volume is zero is ticked.

To see any of this working, set the result list to Detailed log, which the template does. It prints one row per bar with the scores, the open positions and the reason a candidate was or was not entered. It is the only view that shows a constraint refusing a trade, and it is the first thing to switch on when a backtest takes fewer trades than you expected. Switch it back before running anything large; the log is enormous.

Gross exposure is what you own, net exposure is which way you are pointing, and AmiBroker’s Exposure % is neither: it is a time average of how invested you were, and it is the divisor underneath both risk-adjusted return metrics.

The portfolio constraints the backtester enforces are a short, documented list — position counts, per-side counts, size floors, cash and margin, and a fill cap as a share of the entry bar’s volume. Sector caps are not on that list, and pretending otherwise is how a book of ten positions turns out to be one position in disguise.

The arithmetic of correlation is the reason this matters. Ten equally weighted positions with an average pairwise correlation of 0.4 carry the volatility of about two independent positions, and correlations measured in ordinary times understate what happens in the episodes you are sizing against. Plan the ordinary case and the stress case separately, and let the second one decide how many positions you carry.

Check your understanding

Question 1. A long/short book holds six longs at 10% of equity each and four shorts at 10% each. What are gross and net exposure?
Show the answer and why

Answer: Gross 100%, net 20%

Gross is the sum of absolute position values over equity: 60% + 40% = 100%. Net is longs minus shorts: 60% − 40% = 20%. The book is barely exposed to market direction and fully exposed to anything that moves every instrument at once — which is precisely what happens in the episodes that matter.

Question 2. A backtest reports Exposure % of 35%. What does that tell you about the largest amount of capital that was ever at risk in one moment?
Show the answer and why

Answer: Almost nothing — Exposure % is a bar-by-bar time average, so the peak can be far higher

Exposure % is documented as the sum of per-bar exposures divided by the number of bars, where a bar’s exposure is open position value over equity. A system fully invested a third of the time and flat otherwise reports about 33%. The peak concentration has to be read from the trade list or the Detailed log.

Question 3. Ten equally weighted positions have an average pairwise correlation of 0.6. Roughly how many independent positions is that equivalent to?
Show the answer and why

Answer: About 1.6

Book volatility as a fraction of one position is √((1 + 9 × 0.6)/10) = √0.64 = 0.80, and the effective count is 1/0.80² ≈ 1.6. Adding names to a book of correlated things buys far less than the count suggests, and the number that governs the stress case is higher still.

Question 4. Which of these can be enforced with a documented SetOption call? Select all that apply.
Show the answer and why

Answer: A maximum number of simultaneously open positions, A maximum number of open long positions, A minimum position value below which a trade is not entered

MaxOpenPositions, MaxOpenLong and MinPosValue are all documented options. There is no documented per-sector cap: SectorID() can tell you which sector a symbol is in, but imposing a limit that depends on the rest of the book requires the custom backtest procedure, which runs in the backtester’s second phase.

Sources for this lesson

7 verified · checked 2026-09-01

  1. 01AmiBroker User's Guide — Portfolio-level backtesting§ Max. open positions, MaxOpenLong/MaxOpenShort, SeparateLongShortRankamibroker.com/guide/h_portfolio.html2026-08-31
  2. 02AmiBroker User's Guide — System test settings window§ General tab, Portfolio tabamibroker.com/guide/w_settings.html2026-09-01
  3. 03AFL Function Reference — SetOptionamibroker.com/guide/afl/setoption.html2026-08-31
  4. 04AmiBroker User's Guide — System test report window§ Exposure %amibroker.com/guide/w_report.html2026-08-31
  5. 05AFL Function Reference — Correlationamibroker.com/guide/afl/correlation.html2026-08-31
  6. 06AFL Function Reference — Foreignamibroker.com/guide/afl/foreign.html2026-08-31
  7. 07AFL Function Reference — SectorIDamibroker.com/guide/afl/sectorid.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.