Rotation of 5 Momentum stocks (like Clenow)

@BlackCat I haven't had a close look at your code or tested it, but one suggestion is to use your market regime filter in one simple line to block new trades.

Different ways of accomplishing this but one example,

// market regime filter
PositionScore = IIf(inBearMkt , 0, ClenowValue);

Some codes use a similar idea to adjust PositionSize (ie make that zero if the market filter isn't passed).

A couple of other suggestions to test and then compare your results.

Try trading less frequently than every day as that usually just increases your turnover and associated costs without improving your returns.

Test other indexes.

Try taking fewer positions.

All of those look worth testing. Easily said, some are not so easily quantified.

Otherwise it looks like you are off to a good start and welcome to the forum!

4 Likes

Thanks for your reply, @portfoliobuilder. :slightly_smiling_face:
Actually the reason for putting the market filter in the custom backtest object was to preserve any existing positions. In a bear market I only want to stop new positions, and let existing positions slowly be closed as they fail other criteria. I couldn't find a simple way to do this in afl.

Agree with you abt daily trading. I'd prefer to run the system over the weekend and trade Mondays.

Thanks again.

Another possibility,

From the User Guide " the score equal to scoreNoRotate constant means that already open trades should be kept and no new trades entered"

PositionScore = IIf(! inBearMkt , ClenowValue, scoreNoRotate );
1 Like

Thanks @portfoliobuilder , I tried earlier, but scoreNoRotate stopped any open trades being closed (if they failed other criteria). It wasn't obvious to me in the documentation, but Thomasz mentions it here. I'm really looking for a scoreCloseOpenPositions value.

2 Likes

For your 4)

Would I trade this now (Oct 2018) ?
Maybe, with a small amount. The current bull market may have a few years left.
But after 9 years, we are closer to the end than the beginning.
After the bull market ends and its safer to wade back in, I can always add more money.:grinning:

I consciously made the decision to not trade it. For exactly the same reasons as you outline.
My concern was more the 1987 crash that it really didn't handle well and needed 6+years to recover (don't quote me, it was very long). Otherwise you can, of course, fiddle around but I really think if one tries to get the params "most stable", i.e. in a region where changing them doesn't affect performance much, that'd be it. I really try to throw out as many rules as possible. What I could get out (2000-now) was 13% CAR for the $RUI+delisted, incl. rebalancing monthly + comms. + slippage. I'm happy with that. MDD was something like 25%.

Yep, I got exactly the same return for the Russel 3000 for that time, trading weekly (The previous 20% mentioned was a testing error). Got better results from the S&P 500, but I think thats due to the last 10 years passive investing craze, probably not sustainable.

Only additional change I've made is to slowly 'ease in' to the market when the index goes above its MA - don't go all in and buy 100% just cause the index pops above the MA for a day.

I've allocated some money to this for Russel 3000. Momentum strategies do well in the last manic phase of the bull market, and we might be there soon. Right now the index is below the MA, so doing nothing.

1 Like

There's a bug in the above code, jist in case anyone tries to trade on it.

'inBearMkt' needs to be a static variable. Set it:

if( Status( "stocknum" ) == 0 )
{
  StaticVarRemove("inBearMkt");
  FilterIndex = Foreign("$RUA", "Close", 1);
  FilterIndexMA = MA( FilterIndex, 200);
  inBearMkt = FilterIndex < FilterIndexMA;
  StaticVarSet("inBearMkt", inBearMkt);
}

And retrieve it in the custom back tester:

SetCustomBacktestProc("");
if ( Status( "action" ) == actionPortfolio )
{
    inBearMkt = StaticVarGet("inBearMkt");
}

I found that, when not a static variable, the value was not carried over into the Custom Backtester. Whent he start date was in a bear market, the value was wrong.

2 Likes

Thank you BlackCat and Dio (and all the others) for sharing here your valuable experience!

I tested on the S&P500 as well. What was striking for me are bigger drawdowns in the phase 2000,2001 of 40%. I wonder if this is because I did not include the ATR weighting of positions in the code which Clenow does (by the way: I cannot post the code here because it was a cooperation with colleagues, so please do not ask).
So my question is here to the pros: How big is the influence on this ATR weighting? I mean the fact that you would buy smaller portions of more volatile stocks while you buy bigger portions of less volatile.

Risk parity won't do much for reducing drawdowns as momentum has large drawdowns by definition.

Some of the code previously posted appears to be wrong.

The natural/decimal logarithm of the C should be used in the RSquared, not C or BarIndex().

The decimal logarithm can drop the refundant -1 +1 and arrive at the same values as the natural.

Hello friends,

Try to get not enter in falling market so i add "ScoreNoRotate" in my system,
but "^index" below ma period then also find rank and add new position,
i am not understand where my code is wrong.
if some one help me out of this.
Thanks in Advance.
code is below.

if( Status( "stocknum" ) == 0 )
{
  StaticVarRemove("inBearMkt");
  FilterIndex = Foreign("^INDEX", "Close", 1);
  FilterIndexMA = MA( FilterIndex, 200);
  inBearMkt = FilterIndex < FilterIndexMA;
  StaticVarSet("inBearMkt", inBearMkt);
}

stockDisqualified = C < EMA(Close, 200) OR  RankMom >22; 


//SetBacktestMode( backtestRotational );
PositionScore = 1000-RankMom;
PositionScore = IIf(stockDisqualified, 0, PositionScore);  
PositionScore = IIf(inBearMkt,PositionScore,scoreNoRotate);

Hello VIPUL,

Are you missing a 'not' in your last line of code?

PositionScore = IIf(! inBearMkt,PositionScore,scoreNoRotate);

Also,scoreNoRotate may not do what you want it to do. See here. If you set it for one stock, it will stop trading all stocks, and none of them will be sold.

Thanks @BlackCat SIr, for your reply.
I look for it and back with positive review.
Thanks once again.

Hello @BlackCat Sir,
You are right , is not work as i thought.
You know what i have to add in my afl.
can you solve my problem?

Only users with "Verified Badge" are allowed to post on this forum.

My interpretation of Clenow's codes for his Weekly Momentum Rotation Strategy (Stocks on the Move by Andreas Clenow, 2015). This code generated results for 1999.01.01 - 2014.12.31 on S&P500:

  • Recreated Results: CAR 12.06%, MDD -24.41% , Win% 51.94%, Expectancy 3.38%
  • Book Results: CAR 12.30%, MDD -24.00%

Updated results for 2001.01.01 - 2020.12.31 on S&P500 is:

  • Updated Results: CAR 9.41%, MDD -31.76% , Win% 52.42%, Expectancy 2.44%

Notable features in the codes are:

  1. Weekly momentum score ranking using StaticVarGenerateRanks
  2. Stop buying (sell only) when market regime filter is active (when SPX is <200d MA) (CBT)
  3. Bi-Weekly position rebalancing using ATR Risk Parity (CBT)

Grateful for peer review. Many thanks

//Weekly Rotational in S&P500 Momentum Stocks 
//Stocks on the Move - Andreas Clenow
//Code by Wealthero

//Dummy = Optimize("Dummy", 1, 1, 1, 1);
	//Dummy code for WalkForward
  
//Part0--Trading Setups 
  SetTradeDelays(1,1,1,1);
  SetOption("initialequity",100000);
  SetOption("accountmargin", 100);
  SetOption("MaxOpenPositions",50); 
	//Portfolio may have up to 30-40 stocks
	//If set to 10-20 stocks and use ATR based Risk Parity Position Sizing 
	//Return may be low as a portion of Portfolio may be in Cash and under utilized
  SetOption("allowsamebarexit", False);
  SetOption("allowpositionshrinking",False);
  EnableRotationalTrading();
  BuyPrice = O;
  SellPrice = O;
  #include_once "Formulas\Norgate Data\Norgate Data Functions.afl"


//Part1--Date Setups  

 //Portfolio rebalancing every Wed, omitted holiday treatment
  Weekly = DayOfWeek()==3; //Trade on Wed

 //Position rebalancing every 2nd Wed (or 2 weeks)
  CountWed = Cum(Weekly);  //Count Wed
  ReBal = Weekly AND (CountWed % 2 == 0); //Rebalance every 2nd Wed 
	//Credit: Tomasz, Ref: https://www.amibroker.com/kb/2015/01/27/detecting-n-th-occurrence-of-a-condition-using-modulus-operator/

//Part2--Algo Setups
 
	//Ch7: Ranking Stocks -- Adj Regression Slope Scoring 
	 ExpRegperiod = 90;
	 AnnSlope = ((exp(LinRegSlope(ln(C),ExpRegperiod))^250) - 1)*100;
	 R2 = (Correlation(BarIndex(),ln(C), ExpRegperiod))^2;
	 Momentum = R2 * AnnSlope;

	//Ch7: Add'l Filters  

	 //Trading above MA100d
	 IsUp = IIf( C > MA(C,100),1,0);      

	 //No move >15% in past 90days
	 GapMax = GapUp() AND ROC(C,1) > 15;   
	 GapMin = GapDown() AND ROC(C,1) < -15;   
	 IsNoGap = IIf(HHV(GapMax,90) OR HHV(GapMin,90) == 0,1,0); 
	  //Book implied GapUp +15%, but adding GapDn -15% yield better results

	 //Stock is member of S&P500 at time of trade
	 IsSPX = IIf(NorgateIndexConstituentTimeSeries("$SPX") == 1,1,0);

	 //No longer in top 20% (or Top 100 Rank if S&P500)  
	 WorstRank = 100;  
	 SetOption("WorstRankHeld",WorstRank);

	//Ch8: Position Size -- Risk Parity Weighting Method 
	 RiskATR = ATR(20);
	 PositionRisk = (0.001 * 100);
	 PctSize = PositionRisk * BuyPrice / RiskATR;
	 SetPositionSize( PctSize, spsPercentOfEquity );

	//Others: Delisted Symbols
	//Credit: Tomasz, Ref: https://www.amibroker.com/kb/2014/09/26/closing-trades-in-delisted-symbols/
	 bi = BarIndex();
	 lastbi = LastValue( bi ) - Status("BuyDelay");
	 exitLastBar = bi == lastbi;

	 Vix = Foreign("$VIX","C",True);
	 IsDanger = IIf(Vix > 30,1,0);
	
//Part3--Rotational Scoring, putting it all together
 IsKeep = IsNoGap * IsUp * IsSPX; 
	//If anyone equals 0 (or false) then triggers Sell in PositionScore  
 
 PositionScore = IIf(exitLastBar,0,Momentum); 
 //PositionScore = IIf(Rank>=100,0,PositionScore); 
   //Top100 check is done by WorstRank=100 statement in Part2
   //If you want to use Rank here, remove comment out WorstRank
 PositionScore = IIf(IsKeep==0,0,PositionScore);
 PositionScore = IIf(Weekly,PositionScore,scoreNoRotate);


//Part4--Record metrics in StaticVar
	//Credit: Tomasz, Ref: https://www.amibroker.com/kb/2016/01/30/separate-ranks-for-categories-that-can-be-used-in-backtesting/

// Watchlist should contain all symbols included in the test
wlnum = GetOption( "FilterIncludeWatchlist" );
List = CategoryGetSymbols( categoryWatchlist, wlnum ) ;

if( Status( "stocknum" ) == 0 )
{
    // cleanup variables created in previous runs (if any)
	StaticVarRemove( "Value*" );
    StaticVarRemove( "IsBearMkt*" );
    StaticVarRemove( "RiskATR*" );
    categoryList = ",";

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

		//***Duplicate code to generate Ranking in StaticVar
		//***Better UX in exploration auditing, not necessary for Backtest
		//***Somehow PositonScore cannot carry into the Loop, thus replicate here
			ExpRegperiod = 90;
			AnnSlope = ((exp(LinRegSlope(ln(C),ExpRegperiod))^250) - 1)*100;
			R2 = (Correlation(BarIndex(),ln(C), ExpRegperiod))^2;
			Momentum = R2 * AnnSlope;

			IsUp = IIf( C > MA(C,100),1,0);      
			GapMax = GapUp() AND ROC(C,1) > 15;   
			GapMin = GapDown() AND ROC(C,1) < -15;   
			IsNoGap = IIf(HHV(GapMax,90) OR HHV(GapMin,90) == 0,1,0);  
			IsSPX = IIf(NorgateIndexConstituentTimeSeries("$SPX") == 1,1,0);

			bi = BarIndex();
			lastbi = LastValue( bi ) - Status("BuyDelay");
			exitLastBar = bi == lastbi;

			IsKeep = IsNoGap * IsUp * IsSPX;  
			PositionScore = IIf(exitLastBar,0,Momentum); 
			PositionScore = IIf(IsKeep==0,0,PositionScore);
			PositionScore = IIf(Weekly,PositionScore,scoreNoRotate);
			Value = PositionScore;

		//***Necessary Code for Part5--CBT below
		
		//Ch6: Market Regime Filter -- Add new position only if Market is uptrend  
		 Index = Foreign("$SPX","C",True);
		 IndexMA = MA(Index, 200);
		 IsBearMkt = Index < IndexMA;  //For use in CBT ignore signal if BearMkt 
  		
		//Ch8: Position Size -- Risk Parity Weighting Method 
		 //For use in CBT Bi-Weekly Position Rebalance
		 SVarATR = ATR(20);  
		
        RestorePriceArrays();

        //Write Ranked values to a static variable
        StaticVarSet( "Value" + "_" + Symbol, Value);        
        StaticVarSet( "IsBearMkt", IsBearMkt);
        StaticVarSet( "SVarATR" + "_" + Symbol, SVarATR);        
    }
	StaticVarGenerateRanks( "Rank", "Value" + "_", 0, 1234 );
}


//Part5--Custom Backtest Object to: 

// 1) Ignore new buys when market regime filter is false (IsBearMkt)
   //Credit: Tomasz, Ref: https://www.amibroker.com/kb/2014/10/23/how-to-exclude-top-ranked-symbols-in-rotational-backtest/
   //Credit: BlackCat (Post Dec 2018), Ref https://forum.amibroker.com/t/rotation-of-5-momentum-stocks-like-clenow/2736/65 	

// 2) Rebalance positions every 2nd Wed 
   //Credit: Tomasz, Ref: https://www.amibroker.com/kb/2006/03/06/re-balancing-open-positions/

SetCustomBacktestProc("");
if ( Status( "action" ) == actionPortfolio )
{
	bo = GetBacktesterObject();
	bo.PreProcess();

	for ( bar = 0; bar < BarCount; bar++ )
	{
		for ( sig = bo.GetFirstSignal( bar ); sig; sig = bo.GetNextSignal( bar ) )
		{
			IsBearMkt = StaticVarGet( "IsBearMkt");
			
			//Market Regime Filter SPX MA200d
			if (IsBearMkt[bar])
				sig.Price = -1; // exclude signal
		}
	
	bo.ProcessTradeSignals( bar );
	
	CurEquity = bo.Equity;
  
		for( pos = bo.GetFirstOpenPos(); pos; pos = bo.GetNextOpenPos() )
		{
			PosVal = pos.GetPositionValue();
			
			price = pos.GetPrice( bar, "C" );
			xBuyPrice = pos.GetPrice( bar, "O" );
			
			xRiskATR = StaticVarGet( "SVarATR" + "_" + pos.Symbol);
			xPositionRisk = (0.001 * 100);
			xPctSize = xPositionRisk * xBuyPrice / xRiskATR;
			
			diff = PosVal - xPctSize/100 * CurEquity;  
			
			//Rebalance only if difference between desired and
			//current position value is greater than 0.5% of equity
			//and greater than price of single share
			if( ReBal[bar] AND diff[bar] != 0 AND
				abs( diff[bar] ) > 0.005 * CurEquity AND
				abs( diff[bar] ) > price )
			{
				bo.ScaleTrade( bar, pos.Symbol, diff[bar] < 0, price, abs( diff[bar] ));
			}		
			RestorePriceArrays();
		}
	}
	bo.PostProcess();
}


//Part6--Columns in Exploration for checking

if( Status( "Action" ) == actionExplore ) SetSortColumns(2,-4); 

IsBearMkt = StaticVarGet("IsBearMkt");
Symbol = Name(); 
Value = StaticVarGet( "Value" + "_" + Symbol);
Rank = StaticVarGet( "Rank" + "Value" + "_" + Symbol);

Filter = Weekly AND Rank <=50;
//Filter = Weekly; //See all stocks and Ranking
AddTextColumn(FullName(),"Name",1.2);
AddColumn(Momentum,"Score",1.2);
AddColumn(PositionScore,"PosScore",1.2);
AddColumn(Value,"Value",1.2);
AddColumn(Rank,"Rank",1.0);
AddColumn(PctSize,"PctSize",1.2);
AddColumn(IsNoGap,"IsNoGap",1.0);
AddColumn(IsUp,"IsUp",1.0);
AddColumn(IsSPX,"IsSPX",1.0);
AddColumn(IsKeep,"IsKeep",1.0);
AddColumn(IsBearMkt,"IsBearMkt",1.0);
AddColumn(SectorID(), "SectorID", 1.0);
AddTextColumn( SectorID( 1 ), "Sector Name" );

//Lastly set report to Detailed Log to audit for 
// 1) Weekly Portfolio Rebalance -- Entry/Exit
// 2) Bi-Weekly Position Rebalance -- Scale In/Out
// 3) Market Regime Filter -- If SPX is downtrend, then Exit only without adding new positions
 
13 Likes

Hi @Wealthero,
I have virtually simultaneously started a new topic with the idea of developing Andreas' ideas from ground up.
Being an Amibroker newbie, I didn't want to spam this broadly watched thread with stuff that might be boring to all those guys who are doing this for years.

I appreciate your approach of examining GapMax and GapMin, because I've read confusing posts, trying to capture real gaps, while my understanding of the original idea is: we want to filter out "big moves", which do not necessarily have to be gaps.
Nevertheless I ask myself - and you as well as all others - whether a TrueRange would be the best value to limit here (divided by Close of Course).
Overall that should not cause much of a difference, but just for the purpose of accuracy I can't stop wondering about TR vs ROC.

Cheers
Christian

On Page 85 of Clenow's book (Stocks on the Move):

" Second, gaps make me nervous. If there has been any move larger than 15% in the past 90 days, the stock is also disqualified. If you include these situations, there's a risk that you end up getting stocks that are not really momentum situations. Short term shocks may have caused the stock to move significantly, at times even enough to push the volatility adjusted momentum ranking very high. We want long term developments, not sudden gaps."

In Clenow's words, referring to the last sentence, Gaps may be large enough to skew the Momentum Scoring (i.e. Slope x R2). Thus, inferring form Clenow, using True Range instead of Gap (as a filter) may be subjected to the situations Clenow is trying to avoid?

Ultimately, back testing will give the best indication whether True Range or Gaps is more useful. Try substituting with True Range in the code, and remember to share your results with us if you have the answer.

1 Like

Sure I will share,
although as of now I use very different data (TaiPan EOD, german provider), but at least I could compare both ideas' consequences using that.
Just a final word on the logic behind the idea: From my point a major news (e.g. takeover offering) can cause either a huge move (open to close) or a real gap, just depending on whether it was reported during market open hours or not. And I totally agree with your results that down gaps are somewhat more important than up, but then I remember (just as an example) major german companies' (at least three were noted in DAX-30, when it happened) HQs getting visited by prosecutors and police during market open hours, probably not causing any gaps, but quick intraday downturns > 15%, effectively breaking any existing trends in a single session.
I don't want to seem stubborn, but I think catching such events is also covered by the words "any move larger...".

Cheers
Christian

1 Like

A year later - worth noting I am impressed by the quality of your afl work; thanks for posting!

Flattered by your compliments. I just copied and pasted from a lot of intelligent people who contributed to this community =)