Project: A Realistic Portfolio Backtest
Objective
Section titled “Objective”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.
Prerequisites
Section titled “Prerequisites”Everything in this part so far:
Design and constraints
Section titled “Design and constraints”What the formula has to specify
- UniverseA watch list plus a liquidity floor applied on the signal bar. The floor uses only information available before the order.
- Regime gateLong entries only while the instrument is above its long-term average. A gate, not a signal.
- Trigger and exitClose crossing a medium-term average, both ways. Deliberately plain — the point of the project is the machinery around it.
- RiskA maximum-loss stop with ExitAtStop = 2, so a gap through the level costs what a gap costs.
- MoneyEqual weight across ten slots, whole shares, no margin, no interest on cash, slippage in the prices and commission as a percentage.
- 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.
The complete formula
Section titled “The complete formula”Complete runnable 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 parametersPosQty = 10; // maximum simultaneous positionsStartingEquity = 100000;SlippagePercent = 0.05; // per side, percent of priceCommissionPercent = 0.10; // per trade, percent of trade valueMaxLossPercent = 10; // maximum-loss stop distance
RegimePeriod = 200; // long-term trend filterTriggerPeriod = 50; // entry and exit average
MinTurnover = 2000000; // 50-bar average of Close * VolumeMinPriceLevel = 2; // ignore anything trading below thisMinPositionValue = 1000; // below this the trade is not worth entering
// ----------------------------------------------------------- 2. the accountSetOption( "InitialEquity", StartingEquity );SetOption( "MaxOpenPositions", PosQty );SetOption( "AllowPositionShrinking", True );SetOption( "MinPosValue", MinPositionValue );SetOption( "AccountMargin", 100 ); // 100 = fully funded, no marginSetOption( "InterestRate", 0 ); // idle cash earns nothing hereSetOption( "CommissionMode", 1 ); // 1 = percent of tradeSetOption( "CommissionAmount", CommissionPercent );SetOption( "ActivateStopsImmediately", False );SetOption( "ReverseSignalForcesExit", True );SetOption( "AllowSameBarExit", True );
RoundLotSize = 1; // whole sharesSetBacktestMode( 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.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.
How it works
Section titled “How it works”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.
Settings checklist
Section titled “Settings checklist”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. |
Expected result
Section titled “Expected result”Validating trades by hand
Section titled “Validating trades by hand”This is the part of the project that actually matters, and it is not optional.
Pick one trade and reconstruct it
Section titled “Pick one trade and reconstruct it”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:
- The signal bar. Find the bar where
Closecrossed above the trigger average. Confirm the regime condition was also true on that bar, and that the liquidity floor was satisfied. - The entry bar. It must be the next bar. If it is the same bar, your delays are not what you think.
- 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. - 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. - 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 = 2means. - The commission. Entry commission plus exit commission should be
CommissionPercentof 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.
Then check the selection
Section titled “Then check the selection”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.
Common errors
Section titled “Common errors”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.
Extensions
Section titled “Extensions”-
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.
-
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.
-
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.
-
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.
-
Run the cost sensitivity sweep from the costs lesson on this system. Record the cost level at which the conclusion changes.
What changed
Section titled “What changed”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
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 — SetOptionamibroker.com/guide/afl/setoption.html2026-08-31
- 03AFL Function Reference — ApplyStopamibroker.com/guide/afl/applystop.html2026-08-31
- 04AFL Function Reference — SetPositionSizeamibroker.com/guide/afl/setpositionsize.html2026-08-31
- 05AmiBroker User's Guide — Backtest reportamibroker.com/guide/w_report.html2026-08-31
- 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.