Backtesting: Limit Order Fills at Low of Day

I'm having some issues with limit orders that I'd hope someone has experienced in the past, I get the feeling I'm doing something stupid.

I want to replicate the code shown here: https://www.amibroker.com/kb/2014/11/26/handling-limit-orders-in-the-backtester/

I'm trying to execute on the idea of setting a limit order at X percent less than today's close. I've made some modifications example code from the KB to adapt to my criteria, here's what I'm using;

// we run the code on WatchList 2
List = CategoryGetSymbols( categoryWatchlist, 2 );


BuySignal = BuyRule1 AND BuyRule2 AND BuyRule3 AND RegimeFilter AND constituent AND NOT OnSecondLastBarOfDelistedSecurity;

LimitOrder = True;

if (LimitOrder == True)
	{
	if ( Status("stocknum") == 0 ) // Generate ranking when we are on the very first symbol
	{
		 StaticVarRemove( "values*" );

		 for ( n = 0; ( Symbol = StrExtract( List, n ) )  != "";  n++    )
		 {
			 SetForeign ( symbol );

			 // value used for scoring
			 values = ROC(C, lookback);
			 RestorePriceArrays();
			 StaticVarSet (  "values"  +  symbol, values );
			 _TRACE( symbol );
		 }

		 StaticVarGenerateRanks( "rank", "values", 0, 1224 );
	}

	symbol = Name();
	values = StaticVarGet ( "values" +  symbol );
	rank = StaticVarGet ( "rankvalues" +  symbol );
	
	BuyLimitPrice = Ref(C,-1) * Optimize("limit price modifier", 1, 0.97, 1, 0.001);
	BuyPrice=Min(O, BuyLimitPrice);
	Buy = BuySignal AND L < BuyLimitPrice AND rank <= 150;
	}
PositionScore = Random();

Now I've done some possibly stupid things here, but I'm just experimenting, and I'm happy to explain the methodology of where I deviated from the KB, but I don't think my deviations lead to the problem I'm having.

Here's the issue.
Sometimes the order is filled at the stock's low of day. This happens when the low of day (L) is > 'BuyLimitPrice'. This obviously cannot happen in real life, and the order should expire at the end of day when the limit price is not met. I thought the code should be fairly clear that no fill should take place:

	Buy = BuySignal AND L < BuyLimitPrice AND rank <= 150;

That line clearly excludes buying when the L is greater than the 'BuyLimitPrice', but this seems to happen anyways. I am NOT getting any fills unless the 'BuySignal' criteria is met.

If the L is < 'BuyLimitPrice' I are correctly getting filled at the limit price.

Has anyone else experienced this before?

FYI, at this time my maximum positions is set to 1.

Why can your order not be filled at the low of the day ? If you place your orders before the market opens, your order will be in front of the line and it might very likely be filled. The further away your order will be, the higher the likelyhood that you are in front of the line...

But your idea is probably better to be 100% sure.

Just an idea, could it be that you have 3 decimals in your data ?

So you place your order at 123.45 which appears to be the low of the day but in reality it is 123.445 for example.

What you could also do maybe to overcome that problem is

L < (BuyLimitPrice - 0.01)

You have not posted your entire AFL, so it's impossible for anyone else to run it and see what might be going wrong. Also, since you have not used SetOption() or SetTradeDelays() calls to set up your environment, your backtest is influenced by your Analysis Settings. For example, it's crucial to know whether or not you're using trade delays. I suspect that you are and you should not be.

Also, this is almost certainly an error:

Buy = BuySignal AND L < BuyLimitPrice AND rank <= 150;

Since your BuySignal appears to be using end of day conditions and you want to enter on an intraday limit order, your BuySignal needs to be true the day before your entry, so your code should be:

Buy = Ref(BuySignal,-1) AND L < BuyLimitPrice AND rank <= 150;

Finally, it's not a good idea to implement limit orders without a CBT unless you're certain that in live trading you could place orders for all symbols that meet your Setup conditions without exceeding your buying power. Read this entire thread for an explanation: PositionScore / Ranking for trades taken “next day at limit” - #6 by mradtke. For example, in your code it appears that you could potentially place 150 limit orders, though that's unlikely to happen. Is your position sizing so small that your broker would allow you to place all those orders?

@mradtke
I appreciate your support on my post and other forum posts. Thanks for your input. See below for my full AFL code. I've been testing this on Norgate's 'Nasdaq 100 current and past' watchlist. I would be interested if you could reproduce the error.

#include_once "Formulas\Norgate Data\Norgate Data Functions.afl"
OnSecondLastBarOfDelistedSecurity = !IsNull(GetFnData("DelistingDate")) AND (BarIndex() == (LastValue(BarIndex()) -1) OR DateTime() >= GetFnData("DelistingDate") ) ;
OnLastTwoBarsOfDelistedSecurity = !IsNull(GetFnData("DelistingDate")) AND (BarIndex() >= (LastValue(BarIndex()) -1) OR DateTime() >= GetFnData("DelistingDate") );

index_string1 ="S&P 500";
index_string2 ="NASDAQ 100";
index_string3 ="Russell 3000";
constituent = NorgateIndexConstituentTimeSeries(index_string2);

Margin = 0;

SetOption("InitialEquity", 10000);
SetOption("MaxOpenPositions", 1000);
SetOption("AccountMargin",IIf(Margin, 50, 100));
//SetOption("usecustombacktestproc",False);

SetBacktestMode(backtestRegular);
SetTradeDelays(1,1,1,1);

SellPrice=Open;
ShortPrice=Open;
CoverPrice=Open;

maxpos = 1; // maximum number of open positions
SetOption("InitialEquity", 10000 ); // set initial equity = 100K
SetOption( "MaxOpenPositions", maxpos );
SetPositionSize(IIf(Margin, 200, 100)/maxpos, spsPercentOfEquity);
//SetPositionSize(IIf(Margin, 20000, 10000)/maxpos, spsValue);


lookback = 200;

BuyRule1 = MFI(14) < 30;
BuyRule2 = C > Ref(C,-lookback);
BuyRule3 = C < Ref(O, -1);

SPYC = Foreign("SPY","C");
RegimeFilter = SPYC > MA(SPYC, 150);




Sell = C > O;

BuySignal = BuyRule1 AND BuyRule2 AND BuyRule3 AND RegimeFilter AND constituent AND NOT OnSecondLastBarOfDelistedSecurity;

LimitOrder = True;

// we run the code on WatchList 2
List = CategoryGetSymbols( categoryWatchlist, 2 );

if (LimitOrder == True)
	{
	if ( Status("stocknum") == 0 ) // Generate ranking when we are on the very first symbol
	{
		 StaticVarRemove( "values*" );

		 for ( n = 0; ( Symbol = StrExtract( List, n ) )  != "";  n++    )
		 {
			 SetForeign ( symbol );

			 // value used for scoring
			 values = ROC(C, lookback);
			 RestorePriceArrays();
			 StaticVarSet (  "values"  +  symbol, values );
			 _TRACE( symbol );
		 }

		 StaticVarGenerateRanks( "rank", "values", 0, 1224 );
	}

	symbol = Name();
	values = StaticVarGet ( "values" +  symbol );
	rank = StaticVarGet ( "rankvalues" +  symbol );
	
	BuyLimitPrice = Ref(C,-1) * Optimize("limit price modifier", .985, 0.97, 1, 0.001);
	//BuyLimitPrice = Ref(C,-1) * 1;
	BuyPrice=Min(Open, BuyLimitPrice);
	Buy = BuySignal AND L < BuyLimitPrice AND rank <= 150;
	PositionScore = Random();
	}
else
	{
	BuyPrice=Open;
	Buy = BuySignal;
	SetOption("usecustombacktestproc",False);
	values = C/Ref(C,-lookback);
	//PositionScore = Random();
	PositionScore = values;
	}

//Get plottable/exportable equity curve
SetCustomBacktestProc("");

if (Status("action") == actionPortfolio)
{
bo = GetBacktesterObject();
bo.Backtest();
AddToComposite(bo.EquityArray, "~~MR-MFI", "X", atcFlagDeleteValues | atcFlagEnableInPortfolio );
}

I'm using 'SetTradeDelays', can you clarify why this is not good practice. I'm still learning amibroker and I've been using these since day one. I haven't experienced any issue until now.

My code executes the buy order the day after the signal has triggered. The problem is not the trigger but the fill price here's a example from the backtest:

  1. Buy signal triggers on 8/18/2021 for $SWKS, closing price is $175.25 => BuyLimitPrice = 172.62 (calculated using a modifier of 0.985)
  2. Low of day for $SWKS on 8/19/2021 is 173.3, the order should not have been filled as the limit price had not been hit. Yet it was filled at 173.3.

Could trade delays cause this issue? regardless of the delay, why is my order filled at low of day? This isn't a cherry-picked example, this happens systematically through my backtest. backtest says ~95% CAGR with 18% MDD, so that was my first tip-off that something is fishy.

My intention is not to place 150 limit orders, as I mention earlier, this isn't a full system and I need to refine the details. That being said, can you elaborate on why this is an issue? One solution is to have these limit orders set up artificially where my software streams intraday data from my broker and only places the order when the limit price has been hit.

Here's why the rank was set to 150 (as I mentioned in my original post I'm doing some potentially stupid things here). If the rank was set to 1 (or 2/3/4 etc). I'm narrowing down my pool of stocks to the top X by rate of change. Amibroker then looks for a buy condition on those X stocks. That isn't what I want to do, but its placeholder while I sort this limit order issue out.

I actually want to do the reverse of this. To narrow down my pool of stocks by the buy condition, then rank them by rate of change. This would generate many more signals then the reverse. My theory being, if the signal itself is what's generating alpha for me, I don't want a potential signal filtered out because it wasn't in the top X of stocks by ROC. If you have any ideas to create this in AFL I'd love to hear it.

@Henri
Thanks for you input. The issue I'm seeing is that the I'm being filled at the low of day, even if the low of day is > limit price. See the example in my response to mradtke. L < BuyLimitPrice is already in my 'buy' criteria, but the order executes anyways.

Since mradtke's sentence reads like a generalizing statement a la "You should note ever use trade delay or SetTradeDelays or UI delays" it is not true of course. There is no issue with SetTradeDelays or UI trade delays in general. He forgot to add ".. in your code case" to "... you should not be".

You just need to be careful when to use standard trade delays and when to disable them and using Ref() function instead.

First of all you can delay signals:

  1. via UI trade delays of Analysis settings - "Trade" tab
  2. via code by using SetTradeDelays function (which overwrites UI delays but not always, see rotational backtest)
  3. via Ref() function
  4. (In subscripts of looping code e.g. signal[ i - 1 ])

In your case (of partial delays) to make sure that "Trade" delays of analysis settings are getting disabled set code trade delays to zero and use Ref() function for delaying (parts of) signals. Why? Because SetTradeDelays or UI trade delays delay entire trade signal(s) (and positionscore).

// Example snippet to apply partial delays within trade signal(s)
SetTradDelays(0,0,0,0);// disable overall trade delays


// .....

// Delay parts of entire signal
Buy = Ref(BuySignal AND rank <= 150,-1) AND L < BuyLimitPrice;
PositionScore = Ref(Random(),-1);

and for the else part


Buy = Ref(BuySignal,-1);
PositionScore = Ref(values,-1);

Yes, as @fxshrat pointed out, there is nothing wrong with using trade delays when it's appropriate. In the specific example that you posted, it is not appropriate and is the cause of the error that you discovered. Consider this example:

  1. On Monday at EOD, all of your BuySignal conditions are true for stock XYZ. Since the rank requirement is also a "setup" condition which must be true the day before entry, it would make sense to just roll this into the BuySignal variable, but that's just a coding preference, not a requirement.

  2. In your code, you're doing this:
    Buy = BuySignal AND L < BuyLimitPrice AND rank <= 150;,
    which means that on Monday you're checking whether Monday's Low was below the limit price. But in fact you want to be checking Tuesday's low, not Monday's.

  3. By using Trade Delays, you actually enter the trade on Tuesday, but you have no idea whether you should have because you never checked Tuesday's low against the limit. And unless you use SetOption() to disable Price Bound Checking, AmiBroker will modify your BuyPrice to be within the daily range. So if you tell AB that the BuyPrice is 98.00 and the actual low for that bar is 100.00, then AB will change your BuyPrice to 100.00. That's why you're seeing entries that occur at the low of the day.

If you get rid of the trade delays and use Ref() statements as @fxshrat illustrated, then at least you will only get valid trades that enter at prices that actually occurred. But you still have other issues to contend with. Consider another example:

  1. You can have a max of 150 setups that qualify on Monday. AmiBroker will rank your Buy signals from 1 to N based on PositionScore.

  2. Based on the PositionScore ranking (not the ROC ranking done by StaticVarGenerateRanks), assume that the ranks that will actually breach your limit price are ranks 5, 10, 15...

  3. If you have capacity to take 5 trades on Tuesday, AmiBroker will take the trade with ranks 5, 10, 15, 20, and 25. However, in live trading you would have either needed to place 25 orders (and the 25 is just an example), or you would need some sort of automated solution that places orders at the time the limit price is breached.

  4. But that leads us to another problem: The AB backtest will take the 5 highest ranked (by PositionScore) trades. In live trading, you will get the first five stocks that breach the limit price. Using a random position score, your live trading results and your backtested results might be reasonably close, as P/L differences might "come out in the wash". But as soon as you apply any sort of PositionScore based on real data (ROC, RSI, moving average stretch, etc. etc.), then your live results are likely to diverge much further from your backtest results. Think about an example where the stock with the highest position score doesn't breach the limit price until 10 minutes before market close. It's entirely possible that by that time you will have taken all the trades that you have capacity for, and therefore will not be able to enter that highly-ranked trade unless you exit a different trade.

A more conservative approach is to only consider as many setups as you have capacity to enter trades, i.e. if you can take 5 trades today, then only consider the top 5 highest position scores regardless of whether those setups breach the limit price. This approach allows you to place all your orders before the market opens, and the backtest and live results should be nearly identical. However, it will almost surely reduce your exposure (capital utilization), which often translates to greatly reduced performance.

1 Like

This topic was automatically closed 100 days after the last reply. New replies are no longer allowed.