Skip to content
Level 4 · Trading System ResearcherProjectPart 28 · page 7 of 865 min
65Minutes
10AFL functions
6Sources
StandardRequires
AFL functions taught here10

Project: A Realistic Portfolio Backtest

Take the simple trend system you specified in Part 27 and turn it into a portfolio backtest whose every assumption is in the formula rather than in a dialog box — costs, constraints, stop behaviour, liquidity ceiling, ranking — and then validate it by hand before reading a single performance statistic.

The deliverable is not a number. It is a formula whose assumptions somebody else could read and disagree with, plus a validated trade list. If you finish this project with a good-looking result and no hand-checked trade, you have not done it.

Everything in this part so far:

What the formula has to specify

  1. UniverseA watch list plus a liquidity floor applied on the signal bar. The floor uses only information available before the order.
  2. Regime gateLong entries only while the instrument is above its long-term average. A gate, not a signal.
  3. Trigger and exitClose crossing a medium-term average, both ways. Deliberately plain — the point of the project is the machinery around it.
  4. RiskA maximum-loss stop with ExitAtStop = 2, so a gap through the level costs what a gap costs.
  5. MoneyEqual weight across ten slots, whole shares, no margin, no interest on cash, slippage in the prices and commission as a percentage.
  6. Selection and capacityA rank for when candidates outnumber slots, and a ceiling on how much of a day's turnover one position may represent.

Three of those deserve their reasoning stated, because they are where this project differs from a naive one.

The liquidity floor is applied to the signal bar. It uses a 50-bar average of turnover, which is known before the order is placed. A floor applied to the fill bar would be using information from after the decision.

ExitAtStop = 2. The backtest is not awarded a fill at the stop price. It watches the bar’s range, recognises the trigger, and exits at the next bar’s trade price. On daily bars this is the defensible choice, and the gap exploration is how you would justify the alternative if you wanted it.

A turnover ceiling. A position must not represent a large share of what actually traded. This is a crude version of a capacity constraint — it caps entry value against average turnover rather than modelling impact — but a crude constraint applied is worth far more than a sophisticated one skipped.

Complete runnable AFL

project-portfolio-system.afl
// project-portfolio-system.afl
// Part 28 - Project: A Realistic Portfolio Backtest
//
// The simple trend system from the Part 27 project, taken to portfolio level
// with every constraint and every cost written into the formula rather than
// left in a dialog box. Run it as a portfolio BACKTEST over a watch list.
//
// ============================ ASSUMPTIONS ==================================
// Read these before the code. If any one of them is wrong for your market, your
// broker or your account, the result below is a simulation of somebody else's
// trading, not of yours.
//
// Universe Whatever watch list you point the Analysis window at, exactly
// as it stands today. If it was built from a current index
// membership list, it contains survivorship bias and this test
// cannot remove it.
// Period Whatever Range you set. State it whenever you quote a result.
// Bars Daily, unadjusted for anything your data source did not
// already adjust. Splits and dividends are your data vendor's
// problem and they become yours.
// Signal timing Rules are evaluated on the close of the signal bar. Nothing
// in the formula reads a bar that had not printed.
// Fill price The next bar's open, moved against us by SlippagePercent.
// Commission CommissionPercent of trade value, charged on entry and exit.
// Financing Interest on idle cash set to zero. No borrow costs, no
// dividends received, no currency effects.
// Liquidity A turnover floor keeps out names that could not absorb a
// position, and MaxPercentOfTurnover caps how much of a day's
// average turnover one position may represent. Neither is a
// substitute for measuring market impact.
// Stops Maximum-loss stop only, checked against the bar's High-Low
// range but EXITED ON THE NEXT BAR'S OPEN (ExitAtStop = 2). The
// backtest is therefore not awarded a fill at the stop price.
// Sizing Equal weight across PosQty slots, from current portfolio
// equity, whole shares only.
// Selection When signals outnumber slots, the most liquid candidate wins.
// That is a choice with a known side effect - see section 6.
// ===========================================================================
// --------------------------------------------------------- 1. the parameters
PosQty = 10; // maximum simultaneous positions
StartingEquity = 100000;
SlippagePercent = 0.05; // per side, percent of price
CommissionPercent = 0.10; // per trade, percent of trade value
MaxLossPercent = 10; // maximum-loss stop distance
RegimePeriod = 200; // long-term trend filter
TriggerPeriod = 50; // entry and exit average
MinTurnover = 2000000; // 50-bar average of Close * Volume
MinPriceLevel = 2; // ignore anything trading below this
MinPositionValue = 1000; // below this the trade is not worth entering
// ----------------------------------------------------------- 2. the account
SetOption( "InitialEquity", StartingEquity );
SetOption( "MaxOpenPositions", PosQty );
SetOption( "AllowPositionShrinking", True );
SetOption( "MinPosValue", MinPositionValue );
SetOption( "AccountMargin", 100 ); // 100 = fully funded, no margin
SetOption( "InterestRate", 0 ); // idle cash earns nothing here
SetOption( "CommissionMode", 1 ); // 1 = percent of trade
SetOption( "CommissionAmount", CommissionPercent );
SetOption( "ActivateStopsImmediately", False );
SetOption( "ReverseSignalForcesExit", True );
SetOption( "AllowSameBarExit", True );
RoundLotSize = 1; // whole shares
SetBacktestMode( backtestRegular );
SetPositionSize( 100 / PosQty, spsPercentOfEquity );
SetTradeDelays( 1, 1, 1, 1 );
// ------------------------------------------------------------- 3. the fills
// Slippage belongs in the price, not in the commission: it is a worse price,
// not a fee. Remember that AmiBroker clamps any assigned price back inside the
// bar's High-Low range, so on a bar that opened at its high the extra half of
// the spread is silently given back to you.
BuyPrice = Open * ( 1 + SlippagePercent / 100 );
SellPrice = Open * ( 1 - SlippagePercent / 100 );
ShortPrice = Open * ( 1 - SlippagePercent / 100 );
CoverPrice = Open * ( 1 + SlippagePercent / 100 );
// ----------------------------------------------------------- 4. the universe
// Applied to the signal bar, so it uses only what was known before the order.
Turnover = MA( Close * Volume, 50 );
Tradeable = Turnover >= MinTurnover
AND Close >= MinPriceLevel
AND Volume > 0;
// ------------------------------------------------------------- 5. the rules
// Regime: only take longs while the instrument is above its long-term average.
// Trigger: the close crossing above its medium-term average.
// Exit: the same average crossed the other way, or the stop, whichever first.
RegimeMa = MA( Close, RegimePeriod );
TriggerMa = MA( Close, TriggerPeriod );
Regime = Close > RegimeMa;
Buy = Cross( Close, TriggerMa ) AND Regime AND Tradeable;
Sell = Cross( TriggerMa, Close );
// The maximum-loss stop. ExitAtStop = 2 means: watch the whole bar, but leave at
// the NEXT bar's trade price. A gap through the level therefore costs what a gap
// costs, instead of being handed back to us at the level we nominated.
ApplyStop( stopTypeLoss, stopModePercent, MaxLossPercent, 2 );
// ------------------------------------------------------------- 6. the rank
// Liquidity as the tie-break: prefer the candidate we could most plausibly have
// traded. The side effect is that this systematically prefers the largest names
// in the universe, which is a real bias and must be stated whenever the result
// is quoted. Section 8 of the lesson suggests two alternatives worth testing.
PositionScore = Turnover;
// -------------------------------------------------- 7. the liquidity ceiling
// A position must not be a large share of what actually traded. This is a crude
// version of the constraint - it caps the value at entry against average
// turnover rather than modelling impact - but a crude version applied is worth
// far more than a sophisticated one skipped.
MaxPercentOfTurnover = 5;
TurnoverCeiling = MaxPercentOfTurnover / 100 * Turnover;
// Cash we would allocate per slot, expressed in currency, using the starting
// equity as the reference. It does not compound, so it is deliberately
// conservative: as the account grows, the ceiling stays where it was.
SlotValue = StartingEquity / PosQty;
Buy = Buy AND SlotValue <= TurnoverCeiling;
// ------------------------------------------------------ 8. before you believe
// Checklist, in order:
// 1. Settings -> General: periodicity Daily, initial equity matches
// StartingEquity, margin 100, interest 0.
// 2. Settings -> Portfolio: max open positions matches PosQty. The formula
// overrides this, but a mismatch means one of the two is a leftover.
// 3. Settings -> Trades: buy/sell price fields are irrelevant here because
// the formula assigns all four price arrays. Confirm the delays are 1.
// 4. Report: turn "Include trade list in the report" on for this run.
// 5. Run once with the result list on Detailed log. Confirm that on at least
// one bar more candidates appeared than slots, and that the taken ones are
// the top-ranked ones.
// 6. Hand-check one trade against the chart, price by price, before reading
// any statistic in the report.

Download project-portfolio-system.afl141 lines

Read the assumptions block at the top before the code. It is not decoration: it is the part of the deliverable that lets somebody else decide whether your result applies to them. If any single line of it is wrong for your market, your broker or your account, the result is a simulation of somebody else’s trading.

Section 2 sets the account, and it sets everything explicitly — including the options that already have sensible defaults. AccountMargin at 100 means fully funded, no margin. InterestRate at 0 means idle cash earns nothing, so the return figure is attributable to the rules rather than to a cash policy.

Section 3 puts slippage in the prices, in the correct direction on all four arrays, with a comment recording the clamping caveat: on a bar that opened at its high, AmiBroker silently returns the extra half-spread to you.

Section 5 separates regime, trigger and exit. The regime is a gate — it does not generate signals, it removes them. That separation is what makes the system’s behaviour explainable, and it is what lets you test the gate’s contribution by turning it off.

Section 7, the liquidity ceiling, is the one piece most systems omit:

Fragment — not a complete formula

MaxPercentOfTurnover = 5;
TurnoverCeiling = MaxPercentOfTurnover / 100 * Turnover;
SlotValue = StartingEquity / PosQty;
Buy = Buy AND SlotValue <= TurnoverCeiling;

SlotValue uses the starting equity rather than current equity, so it does not compound. That is deliberate and conservative: as the account grows, the ceiling stays where it was, which under-states rather than over-states your capacity. Compounding it would let the backtest quietly take larger and larger positions in the same thin names.

The formula overrides most of these, but a mismatch between formula and dialog usually means one of the two is a leftover from a different experiment. Check all five before you run.

Where What to confirm
Settings → General Periodicity is Daily. Initial equity matches StartingEquity. Margin 100. Interest rate 0.
Settings → Portfolio Max open positions matches PosQty.
Settings → Trades Delays are 1 on all four. The buy/sell price fields are irrelevant here because the formula assigns all four price arrays — confirm that is what you intended.
Analysis window Apply to points at the watch list you mean. The Range is the period you will quote.
Report settings “Include trade list in the report” on for this run.

This is the part of the project that actually matters, and it is not optional.

Open the trade list, pick a completed trade in the middle of the range — not the first, not the last — and open that symbol’s chart over the same dates. Then check, in order:

  1. The signal bar. Find the bar where Close crossed above the trigger average. Confirm the regime condition was also true on that bar, and that the liquidity floor was satisfied.
  2. The entry bar. It must be the next bar. If it is the same bar, your delays are not what you think.
  3. The entry price. It must be that bar’s open, multiplied by 1 + SlippagePercent / 100 — unless the open was the bar’s high, in which case it will be the high exactly. Check for that case specifically; it is the clamping behaviour, and seeing it once makes it real.
  4. The share count. floor( position value / entry price ) with whole shares. The position value should be one tenth of the equity shown for that bar.
  5. The exit. Which of the two exits fired — the rule or the stop? If it was the stop, confirm that a prior bar’s range went below the stop level and that the exit is on the following bar, which is what ExitAtStop = 2 means.
  6. The commission. Entry commission plus exit commission should be CommissionPercent of each leg’s value.

If all six agree, you have earned the right to read the summary statistics. If any one disagrees, find out why before you read anything, because the same discrepancy is in every other trade too.

Set the result list to Detailed log and re-run. Find a bar where more entry candidates appeared than slots were free, and confirm that the entries taken are the top-ranked ones and that the rejected candidates are named with a reason.

Every trade enters on the signal bar. Delays are zero somewhere — either SetTradeDelays() is missing or the Settings dialog is overriding. The formula’s call should win; if it does not, you are editing a different file than the one being run.

The account holds one position no matter what. SetPositionSize() is at 100% of equity. See portfolio backtesting.

Far fewer trades than expected, and long dormant stretches per symbol. The skipped-signal cascade in backtestRegular. Re-run with backtestRegularRaw to see how much of the gap is the mechanism rather than the constraint.

Exit prices are exactly the stop level, every time. ExitAtStop is 1 somewhere — check the fourth argument of ApplyStop. This makes every result better and every result fictional on daily data.

Results change when you re-run with no edits. Something is reading a dialog rather than the formula, or the data changed underneath you. Save the .APX, re-run from it, and confirm the result reproduces before you trust anything.

Positions in symbols that barely trade. The liquidity floor is applied on the wrong bar, or the turnover ceiling is not doing its job. Add a column to an exploration showing Turnover on the signal bar for every trade you took and look at the smallest values.

A spectacular result. Treat this as a bug report, not a discovery. Work through Part 30 before you spend another minute on it — the impossible backtest challenge exists precisely for this moment.

  1. Turn the regime gate off and re-run. How much of the result came from the gate rather than the trigger? This is the cheapest and most informative single experiment in the whole project.

  2. Replace the liquidity rank with two alternatives and compare: rank by lowest volatility, and rank by proximity to the trigger average. Same rules, three different selection policies, three different trade lists. This is the experiment that tells you whether your score is doing anything.

  3. Add a volatility-based position size using the ATR sizing from Part 34, and compare against equal weight on identical signals. That comparison is the Part 34 lab, run early.

  4. Make the turnover ceiling compound with equity and observe what changes. Then decide which version you believe, and write down why. This is a small change that materially alters the capacity assumption.

  5. Run the cost sensitivity sweep from the costs lesson on this system. Record the cost level at which the conclusion changes.

You now have a portfolio backtest in which every assumption is a named constant at the top of a file rather than a checkbox somebody might have changed. You have validated trades by hand against a chart, which means you know the engine is doing what you believe rather than hoping so. And you have a stop configuration that does not award you fills at prices the market never offered.

None of that makes the result true. It makes the result checkable, which is the only property that lets anything else follow.

Check your understanding

Question 1. Why does the turnover ceiling use StartingEquity / PosQty rather than current equity?
SlotValue = StartingEquity / PosQty;
Buy       = Buy AND SlotValue <= TurnoverCeiling;
Show the answer and why

Answer: Because it does not compound, so as the account grows the ceiling stays put — which under-states rather than over-states capacity

It is a deliberately conservative choice. A compounding ceiling would let the simulation take progressively larger positions in the same thin names, which is exactly the capacity problem the constraint exists to limit.

Question 2. While hand-checking a trade you find the entry price is the bar's High exactly, not Open * 1.0005. What has happened?
Show the answer and why

Answer: The bar opened at its high, so the slippage-adjusted price fell outside the High-Low range and was silently clamped to the High

Price-bound checking clamps assigned prices into the bar range. On a bar that opened at its high, the added slippage is silently removed. Finding this during a hand-check is how the behaviour becomes real rather than theoretical.

Question 3. Which checks must be completed before reading the summary statistics? Select all that apply.
Show the answer and why

Answer: At least one trade reconstructed bar by bar against the chart, The entry bar confirmed to be one bar after the signal bar, The Detailed log confirming that the entries taken on a constrained bar were the top-ranked candidates

The first three verify that the engine is doing what you believe. A performance threshold is not a validation step — it is a result, and reading results before validating the machinery that produced them is how people end up trading a delay error.

Question 4. Your backtest shows exit prices that are exactly the stop level on every stopped-out trade. What should you check?
Show the answer and why

Answer: The fourth argument of ApplyStop — ExitAtStop = 1 awards a fill at the nominated level on the triggering bar, including on bars that gapped past it

ExitAtStop = 1 checks the High-Low range and fills at the stop level. On daily bars that assumption is not defensible without measuring gap frequency on your own data, which is what the gap-through-stop exploration is for.

Question 5. True or false: because every assumption is written into the formula, the backtest result can be quoted as an expected return.
Show the answer and why

Answer: False

Explicit assumptions make a result checkable and reproducible. They do not make it predictive. It remains one observation from one history over one universe with one set of parameters — and it still carries whatever survivorship bias the universe was built with.

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 — SetOptionamibroker.com/guide/afl/setoption.html2026-08-31
  3. 03AFL Function Reference — ApplyStopamibroker.com/guide/afl/applystop.html2026-08-31
  4. 04AFL Function Reference — SetPositionSizeamibroker.com/guide/afl/setpositionsize.html2026-08-31
  5. 05AmiBroker User's Guide — Backtest reportamibroker.com/guide/w_report.html2026-08-31
  6. 06AmiBroker User's Guide — Back-testing your trading ideasamibroker.com/guide/h_backtest.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.