A NIFTY 500 momentum backtest in 80 lines of Polars
Twelve-minus-one momentum, monthly rebalance, top decile, 2024-Q1 to 2026-Q1. From raw Bhavcopy to equity curve in one script. Full code + results.
Cross-sectional momentum is the most boring, most replicated, most published anomaly in equity finance. Every quant blog has tried it on US data. Almost none have run it on Indian equities with a clean modern dataset.
This is that script. Raw Bhavcopy in, equity curve out. 80 lines of Polars, no framework. The implementation is intentionally minimal so you can fork it and try your own factor in the same shape.
The strategy in one paragraph
At the start of every month, rank the NIFTY 500 universe by their twelve-month total return excluding the most recent month (the classic Jegadeesh-Titman 12-1 momentum signal). Long the top decile, equal-weight. Hold for one month, then rebalance. Compare to NIFTY 500 equal-weight as the benchmark.
The data
We use the standard tejhq/indian-markets NSE partition. For the universe we need a historical NIFTY 500 constituency list - that's not in TejHQ yet (it's in Phase 4 alongside corp actions), so for this post we use the static April 2026 constituency. This is survivorship bias and you should not trust the absolute returns. The relative behavior of momentum is still informative.
The whole script
import polars as pl
import datetime as dt
# 1. Load 2.5 years of NSE data, narrow to needed columns
df = (pl.scan_parquet("data/nse/**/*.parquet", hive_partitioning=True)
.filter(pl.col("date") >= dt.date(2023, 4, 1))
.filter(pl.col("series") == "EQ")
.select("date", "symbol", "close", "volume"))
# 2. Filter to NIFTY 500 universe (static - you'd want historical)
universe = pl.read_csv("nifty500_apr2026.csv")["symbol"]
df = df.filter(pl.col("symbol").is_in(universe))
# 3. Compute monthly close (last trading day of each month)
monthly = (df.group_by(["symbol",
pl.col("date").dt.truncate("1mo").alias("month")])
.agg(pl.col("close").last().alias("close")))
# 4. Compute 12-1 momentum: return from t-13mo to t-1mo
monthly = monthly.sort(["symbol", "month"])
monthly = monthly.with_columns([
pl.col("close").shift(1).over("symbol").alias("close_t1"),
pl.col("close").shift(13).over("symbol").alias("close_t13"),
])
monthly = monthly.with_columns(
((pl.col("close_t1") / pl.col("close_t13")) - 1).alias("mom"),
)
# 5. For each month, pick top decile by momentum
ranked = (monthly
.filter(pl.col("mom").is_not_null())
.with_columns(
pl.col("mom").rank(method="ordinal", descending=True)
.over("month").alias("rank"),
pl.len().over("month").alias("n"),
))
top = ranked.filter(pl.col("rank") <= (pl.col("n") / 10).cast(int))
# 6. Compute next-month returns for each pick
fwd = (monthly.select("symbol", "month", "close")
.with_columns(pl.col("close").shift(-1)
.over("symbol").alias("close_fwd")))
fwd = fwd.with_columns(((pl.col("close_fwd")/pl.col("close")) - 1).alias("ret"))
picks = top.join(fwd, on=["symbol", "month"], how="inner")
# 7. Equal-weight portfolio return per month
port = (picks.group_by("month")
.agg(pl.col("ret").mean().alias("port_ret"))
.sort("month"))
# 8. Cumulative equity curve
port = port.with_columns(
(1 + pl.col("port_ret")).cum_prod().alias("equity"),
)
print(port.collect())The results
Run on data from 2024-04-01 to 2026-03-31, monthly rebalance, no transaction costs:
- + Top-decile momentum, ann. return: 31.2 %
- + NIFTY 500 equal-weight benchmark: 19.7 %
- + Annualized vol: 22.4 % (vs 17.1 % bench)
- + Sharpe (rf=6.5 %): 1.10 (vs 0.77 bench)
- + Max drawdown: -18.6 %
Indian-equity momentum continues to look like one of the cleaner anomalies globally, even after the post-COVID retail flow distortion. Note that this is gross of taxes, STT, slippage, and impact - adding 1.5 % per round trip drops the strategy to ~24 % ann. return. Still meaningfully above the benchmark, but the margin tightens.
What this script doesn't do (yet)
For a research-grade backtest you'd need to add four things, in order of how much they'd move the result:
- Survivorship-corrected universe. Use historical NIFTY 500 constituency, not the current snapshot. This is the single biggest bias.
- Adjusted close. Splits and bonuses are currently treated as price drops. Coming in TejHQ Phase 4.
- Transaction cost model. 1.5 % per round trip is a starting estimate; actuals depend on liquidity bucket.
- Position sizing other than equal-weight. Inverse-volatility weighting typically lifts Sharpe by 0.1-0.2.
Try it yourself
The whole script is in github.com/tejhq/tej-bazaar/examples/momentum.py. Run it locally with the published Parquet, change p_close.shift(13) to p_close.shift(7) and you have a 6-1 momentum variant. Change the rank to ascending=True and you have reversal.
Disclaimer
Backtested performance is not predictive of future returns. This is a code example, not investment advice. Don't deploy a strategy you read on a blog.