This week I shipped a sector rotation map for Indian stocks. It plots NSE sector and thematic indices against the Nifty 500 and sorts them into four corners: Leading, Weakening, Lagging and Improving.
Then the obvious question hit me: does it actually tell you anything? If you buy whatever is Leading, do you beat the market? And do sectors really travel clockwise around the map, the way every explainer says?
So I rebuilt the whole thing in Python, ran it over ten years of weekly data, and checked. This post is the build: the maths, the code, two bugs, and a shuffle test that took my favourite finding away. The story version, with more charts, is on Medium (linked at the end).
Not investment advice. I built the data platform and the map used here. Data runs from July 2016 to 23 October 2026.
The maths
Every week, for every sector:
rs = 100 * sector / nifty_500 # relative strength
ratio = 100 * wma(rs, 5) / wma(rs, 15) # has it been gaining ground lately?
mom = 100 * ratio / ratio.shift(5) # is that speeding up or fading?
wma is a weighted moving average, where the newest week counts most. Dividing the 5-week average by the 15-week one gives a number around 100. Above 100 means relative strength has been rising recently, so the sector has been outperforming the Nifty 500 over the last few weeks. The momentum line compares today's ratio with five weeks ago. Above 100, that outperformance is accelerating.
Two numbers, two thresholds at 100, four corners:
q = np.where(ratio >= 100, np.where(mom >= 100, "Leading", "Weakening"),
np.where(mom >= 100, "Improving", "Lagging"))
The formula was the easy part. Every mistake I made was in the data handling.
Getting the data
I pulled daily levels for 21 indices and the Nifty 500 from the data API I built. Responses are paginated, so a small helper follows meta.next_cursor until has_more is false. The from date is 2015, so the 15-week averages have time to warm up before the test starts in July 2016.
Weekly bars, finished weeks only
iso = daily.index.isocalendar()
weekly = daily.groupby([iso.year, iso.week]).last()
weekly.index = [pd.Timestamp.fromisocalendar(y, w, 5) for y, w in weekly.index]
if weekly.index[-1] >= pd.Timestamp.today().normalize():
weekly = weekly.iloc[:-1] # this week hasn't finished yet
Grouping by ISO week and taking the last close gives one bar per week, holiday weeks included. Each week is labelled with its Friday, and if that Friday hasn't passed, the week is dropped. Run it on a Wednesday without that check and you're treating three days as a full week. My first version dropped the last week unconditionally, which quietly threw away a finished week whenever I ran it on a weekend.
The live map had a cousin of this bug. In weekly mode, a freshness check that looked at the wrong date decided, in the middle of the week, that every sector was stale, and the map came up empty. The fix was to compare each sector's last session with the map's as-of date.
The bug that almost fooled me
My first version built the daily table like this:
daily = pd.concat(series, axis=1).sort_index().ffill()
Forward-filling looks harmless. Holidays differ a little between indices, so why not carry the last price forward?
Because three of the indices (Healthcare, Consumer Durables, Oil & Gas) have a stray data point in mid-2016 and no continuous data until 2020. ffill() took that one price and copied it forward for years. Healthcare got about four years of invented, perfectly flat history.
Nothing errored, but the answer flipped. With the fill, Improving sectors beat the Nifty 500 over the next quarter by 0.30% on average. Without it, they lagged by 0.36%.
I caught it only because an earlier prototype built the weeks differently and the two disagreed. The fix was to delete .ffill():
daily = pd.concat(series, axis=1).sort_index()
GroupBy.last() already skips missing values inside a week, so holidays are fine, and a week with no data at all stays empty. The rolling averages see the gap, return NaN, and drop out instead of inventing a trend.
A second, smaller one, which a reviewer caught: I first computed the 13-week forward return after dropping the empty rows. Across a gap, shift(-13) then jumps 13 rows, not 13 weeks. The forward return is now computed on the full weekly series, before anything is dropped.
Checking it against the live map
Before trusting the backtest, I printed the latest week and compared it with the live page. All 21 indices matched the live page to two decimals on both ratio and momentum. IT: ratio 104.76, momentum 104.49. Pharma: 101.35 and 99.30. MNC: 97.33 and 98.19.
Result one: Leading doesn't predict much
For every weekly reading from July 2016, I recorded the corner and whether the sector beat the Nifty 500 over the next 13 weeks:
weeks avg beat_benchmark
quadrant
Improving 1707 -0.36% 47.9%
Lagging 3620 -0.28% 47.0%
Leading 3443 +0.47% 50.1%
Weakening 1616 +0.21% 47.8%
Any sector-week beat the benchmark 48.3% of the time, so Leading is about two points better. But the 13-week windows overlap, which makes 10,386 readings worth far fewer independent ones. When I kept only non-overlapping quarters, Weakening (+1.14%, 132 readings) came out ahead of Leading (+0.26%, 253). That flip looks like noise, so I wouldn't treat either number as an edge. These are also price indices, so dividends are left out. That slightly understates sectors that yield more than the Nifty 500.
Result two: the shuffle test
The clockwise rotation looked like the real finding. 67.0% of the 2,233 corner changes went clockwise.
Then I asked what random data would do. For each sector, I took the longest unbroken stretch of its real weekly moves, shuffled their order, rebuilt a relative-strength path, and ran the exact same map on it, 30 times:
s = rs.dropna()
run = (s.index.to_series().diff() > pd.Timedelta(weeks=2)).cumsum()
s = s[run == run.value_counts().idxmax()] # longest unbroken stretch only
moves = np.log(s).diff().dropna().to_numpy()
path = np.exp(np.log(s.iloc[0]) + np.concatenate([[0], np.cumsum(rng.permutation(moves))]))
fake.append(readings(pd.Series(path, index=s.index))[lambda d: d.index >= "2016-07-01"])
real sectors: 2233 quadrant changes, 67.0% clockwise
shuffled data: 66.2% clockwise (range 63.6% to 68.0%)
The shuffled data rotates clockwise just as reliably: real sectors, at 67.0%, sit inside the shuffled range. Even the median time in Leading is the same: six weeks for real sectors, five to six for shuffled ones.
The reason is in the maths. The momentum axis is roughly the rate of change of the ratio axis. When momentum is above 100, the ratio is rising, so the dot moves right; below 100 it moves left. Right along the top and left along the bottom is clockwise, for any wiggly series, like a pendulum's position against its speed. And because the ratio compares a short and a long average of the same series, it swings around 100 for any input, so those loops circle the centre of the map. The rotation comes from the indicator itself. It's still a nice way to see a sector's recent path. It just isn't evidence of a cycle you can trade.
The full script
Set TAPETIDE_API_TOKEN and run it. It prints the latest map, the results table, the clockwise count and the shuffle test.
import os
import numpy as np
import pandas as pd
import requests
API = "https://api.tapetide.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['TAPETIDE_API_TOKEN']}"}
SECTORS = ["nifty-it", "nifty-bank", "nifty-private-bank", "nifty-psu-bank", "nifty-fmcg",
"nifty-pharma", "nifty-auto", "nifty-metal", "nifty-realty", "nifty-energy",
"nifty-financial-services", "nifty-infrastructure", "nifty-media", "nifty-mnc",
"nifty-pse", "nifty-cpse", "nifty-commodities", "nifty-india-consumption",
"nifty-consumer-durables", "nifty-healthcare-index", "nifty-oil-and-gas"]
ORDER = ["Leading", "Weakening", "Lagging", "Improving"]
def closes(slug, start="2015-01-01"):
rows, params = [], {"from": start, "limit": 1000}
while True:
body = requests.get(f"{API}/indices/{slug}/levels", headers=HEADERS, params=params, timeout=60).json()
rows += body["data"]
if not body["meta"]["has_more"]:
break
params["cursor"] = body["meta"]["next_cursor"]
s = pd.DataFrame(rows)
return pd.Series(s["close"].values, index=pd.to_datetime(s["date"]), name=slug)
daily = pd.concat([closes(s) for s in SECTORS + ["nifty-500"]], axis=1).sort_index()
iso = daily.index.isocalendar()
weekly = daily.groupby([iso.year, iso.week]).last() # last real close per week; empty weeks stay NaN
weekly.index = [pd.Timestamp.fromisocalendar(y, w, 5) for y, w in weekly.index]
if weekly.index[-1] >= pd.Timestamp.today().normalize():
weekly = weekly.iloc[:-1] # this week hasn't finished yet
def wma(x, n):
weights = np.arange(1, n + 1)
return x.rolling(n).apply(lambda v: v @ weights / weights.sum(), raw=True)
def readings(rs):
ratio = 100 * wma(rs, 5) / wma(rs, 15) # is relative strength rising lately?
mom = 100 * ratio / ratio.shift(5) # is that speeding up or fading?
q = np.where(ratio >= 100, np.where(mom >= 100, "Leading", "Weakening"),
np.where(mom >= 100, "Improving", "Lagging"))
next_13w = rs.shift(-13) / rs - 1 # beat the benchmark next quarter?
d = pd.DataFrame({"rs": rs, "ratio": ratio, "mom": mom, "quadrant": q, "next_13w": next_13w})
return d[d["ratio"].notna() & d["mom"].notna()]
def clockwise_share(frames):
moves = [(a, b) for d in frames for a, b in zip(d["quadrant"], d["quadrant"].iloc[1:]) if a != b]
return np.mean([ORDER[(ORDER.index(a) + 1) % 4] == b for a, b in moves]), len(moves)
rs_all = {s: 100 * weekly[s] / weekly["nifty-500"] for s in SECTORS}
frames = [readings(rs)[lambda d: d.index >= "2016-07-01"] for rs in rs_all.values()]
panel = pd.concat(frames, keys=SECTORS, names=["sector", "week"])
print(panel.xs(panel.index.get_level_values("week").max(), level="week").round(2)
.sort_values("ratio", ascending=False).drop(columns="next_13w").to_string())
test = panel.dropna(subset=["next_13w"])
print(test.groupby("quadrant")["next_13w"].agg(
weeks="size", avg=lambda v: f"{v.mean():+.2%}", beat_benchmark=lambda v: f"{(v > 0).mean():.1%}"))
share, n = clockwise_share(frames)
print(f"real sectors: {n} quadrant changes, {share:.1%} clockwise")
# The same map built from shuffled weekly moves: same sizes, no real pattern.
rng = np.random.default_rng(11)
shuffled = []
for _ in range(30):
fake = []
for rs in rs_all.values():
s = rs.dropna()
run = (s.index.to_series().diff() > pd.Timedelta(weeks=2)).cumsum()
s = s[run == run.value_counts().idxmax()] # longest unbroken stretch only
moves = np.log(s).diff().dropna().to_numpy()
path = np.exp(np.log(s.iloc[0]) + np.concatenate([[0], np.cumsum(rng.permutation(moves))]))
fake.append(readings(pd.Series(path, index=s.index))[lambda d: d.index >= "2016-07-01"])
shuffled.append(clockwise_share(fake)[0])
print(f"shuffled data: {np.mean(shuffled):.1%} clockwise (range {min(shuffled):.1%} to {max(shuffled):.1%})")
What I'd try next
- A better null model. Shuffling weekly moves breaks every streak. A block shuffle that keeps short runs intact would be a tougher test.
- The daily map. The live page also has a daily version with 14/36/8 settings.
- Run every new pattern past random data first. That one is less an idea than a rule now.
The live map is at tapetide.com/insight/sector-rotation, on Tapetide, the data platform I built. The findings and charts are in the companion piece on Medium. Not investment advice.

Top comments (0)