A Better Stock Rotation System

A stock rotation system is normally a safe haven, compared to other algorithmic systems. There’s no risk of losing all capital, and you can expect small but steady gains. The catch: Most of those systems, and also the ETFs derived from them, do not fare better than the stock index. Many fare even worse. But how can you make sure that your rotation strategy beats the index? There is a way.

In the TASC July 2026 issue, Markos Katsanos suggests a solution for a better stock rotation system. He applies two twists: Excluding the top performers, which often experience a reversal to the mean, and filtering out bear market situations. This improves stock rotation systems a lot.

The code of his system is unfortunately written in Amibroker languge, which means that for using it with any other platform, one must rewrite it from scratch. Amibroker does not use buy or sell orders. Instead it has a ‘portfolio rotation mode’ that is set up with many variables. Zorro’s C language has no special rotation mode, but uses buy and sell orders for rotating, just as for any other strategy. This requires rewriting the Amibroker code, but the positive side is that the script becomes much shorter and easier to comprehend.

var Score[1000],Weights[1000];

void run()
{
  StartDate = 2012;
  EndDate = 2025;
  BarPeriod = 1440;
  LookBack = 252; // 1 year

  Capital = slider(1,10000,0,20000,"Capital","");
  assetList("AssetsNASDAQ");
  assetAdd("QQQ","STOOQ:QQQ"); // for the bear market detection
  asset("QQQ");
// set up variables
  int MaxOpenPositions = 15;
  int ROCBars = 100;
  int ExitBars = 20;
  int MAPeriod = 300;
  int ExcludeTopN = 2;
// bear market filter
  var MAQQQ = ZMA(seriesC(),MAPeriod);
  bool Bear = MAQQQ < ref(MAQQQ,1);
  if(Day%ExitBars == 0) {
// assign a score to any asset
     for(listed_assets) {
       asset(Asset);
       if(Asset == "QQQ" || Bear)
         Score[Itor] = 0; // don't trade the index
       else
         Score[Itor] = ROC(seriesC(),ROCBars);
     }
// exclude the N top scores
     int i;
     for(i=0; i<ExcludeTopN; i++)
       Score[MaxIndex(Score,NumAssetsListed)] = 0;

// rotate the positions
    distribute(Weights,Score,NumAssetsListed,MaxOpenPositions,0.5);
     rotate(0); // decrease positions
     rotate(1); // increase positions
   }
}

We’re loading all NASDAQ stocks from an asset list (AssetsNASDAQ), and add the ‘QQQ’ index ETF because we’re needing that for the bear market filter. The MAQQQ variable holds the average index value, determined with a zero-lag moving average (ZMA). The ZMA has two advantages over a standard moving average: faster reaction (as the name says) and not needing a long data history. We assume a bear market when the average is falling.

Next, we check if we have reached the rotation date (Day%ExitBars is the number of days since start modulo the number of days for a rotation). If so, we loop over all assets and assign every one a score, depending on its N-day rate of return (ROC). The Itor variable is the number of the asset in the loop. The QQQ index gets no score, and in a bear market none of them gets a score.

Next, we remove the two top performers, since we assume they are overbought. The distribute function takes the scores and converts them to weights, while all weights sum up to 1. The function can be looked up in the Zorro manual (https://zorro-project.com/manual/en/renorm.htm). Finally we perform the actual rotation. This is a bit tricky, because we need two steps. The first step reduces all positions that ought to be reduced. The second step increases all positions that ought to be increased. This order is important, because if we increased a position first, the total volume could exceed our capital on the broker account.

The rotate function is not a Zorro function, but just assigns new position sizes to any asset of the portfolio:

void rotate(int Buy)
{
  for(listed_assets) {
    asset(Asset);
    int NewLots = Capital*Weights[Itor]/MarginCost;
    if(NewLots < LotsPool)
      exitLong(0,0,LotsPool-NewLots);
    else if(Buy && NewLots > LotsPool)
      enterLong(NewLots-LotsPool);
  }
}

Since I see from Markos Katsanos’ code that he optimized his variables, I have to do the same. For this, his variables now get optimization ranges:

set(PARAMETERS); // parameter optimization
setf(TrainMode,TRADES|GENETIC); // size matters
int MaxOpenPositions = optimize(15,5,30,5);
int ROCBars = optimize(100,50,250,50);
int ExitBars = optimize(20,10,50,5);
int MAPeriod = optimize(300,100,1000,100);
int ExcludeTopN = optimize(2,1,5,1);

We’re using genetic optimization with considering the trade volume (TRADES|GENETIC). The optimization takes about one minute. It’s in-sample, so take the result with a grain of salt. This is the equity curve resulting from a backtest:

In the backtest, we’re reinvesting profits; for this replace Capital with Equity in the rotate function. The blue bars are the account equity, the black line is the QQQ index. We can see that the account has far less drawdowns than the index. The black line in the small chart below is our trade volume, which is zero when a bear market is detected. The green line is the QQQ average, with bear market situations indicated in red. In 2022, the year when the Russian attack on Ukraine began, the system did not trade at all since the bear market filter was active almost the whole year.

The system produces 32% CAGR, with a 14% worst drawdown. This replicates Markos Katsanos’ results, but again, keep in mind that this is from an in-sample optimization. When applying walk-forward optimization (left as an exercise to the reader :), the CAGR goes down to 22%. Still a good performance, well beyond the NASDAQ index.

The code can be downloaded from the 2025 script repository.

3 thoughts on “A Better Stock Rotation System”

  1. Does anyone know if they’re using the Nasdaq 100 stocks or the entire Nasdaq database (5000 stocks) or which stock they used exactly? Thanks

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.