Data ScienceIndustry analytics playbooks

Mandi price volatility: modal prices, returns and seasonal bands

PK
Pankit Kumar
Sr. Data Scientist at Parexel (a Goldman Sachs–backed company) · 27 September 2026 · 6 min read
In this article (6 sections)

Related programme: the Data Science course (6 months; ₹1,30,000 (inclusive of GST) · EMI from ₹5,000/month).

The short answer

To measure mandi price volatility, take the daily modal price for one commodity in one market, compute log returns between consecutive trading days, and report the rolling standard deviation of those returns (for example over 30 trading days). Before that, remove data-entry errors and skip returns across long market closures, and judge whether a price is unusual against a seasonal band built from earlier years rather than against last month.

Who asks, and what they actually need to decide

A food-processing company buying onion or tomato, a warehouse-receipt lender taking a commodity as collateral, a state agriculture department monitoring distress sales, or a farmer-producer organisation deciding when to sell all ask versions of one question: is the current price unusual for this time of year, and how much could it move before we act? The first half needs a seasonal comparison; the second needs a volatility measure. Mixing the two is the most common analytical mistake with this data.

What a mandi price file contains

Agmarknet (agmarknet.gov.in) publishes arrivals and prices reported by regulated markets, with minimum, maximum and modal prices per quintal by market and arrival date. A typical extract, however you pull it, has this shape:

ColumnMeaningTrap
state, district, marketwhere the price was reportedmarket names are spelled inconsistently across years
commodity, variety, gradewhat tradedvariety mix changes by season
arrival_datetrading dayno row on closures; not every day exists
arrivals_tonnesquantity arrivingzero or missing on thin days
min_price, max_price, modal_price₹ per quintaltyping slips, such as an extra zero

Treat each market–commodity–variety combination as its own series. Averaging modal prices across markets hides the spread you usually want to see.

Four steps in pandas, on synthetic prices

The series below is generated for illustration (₹ per quintal, one market, January 2024 to September 2026) with a seasonal cycle, Sunday and random closures, and a rare data-entry slip. It is not Agmarknet data.

python
import math
import random
import pandas as pd

# Synthetic modal prices (Rs per quintal) for one commodity in one market,
# Jan 2024 - Sep 2026. Not real Agmarknet data.
rng = random.Random(42)
rows, price = [], 2000.0
for d in pd.date_range("2024-01-01", "2026-09-30", freq="D"):
    if d.dayofweek == 6 or rng.random() < 0.05:      # Sundays and random closures: no row
        continue
    season = 1 + 0.35 * math.sin(2 * math.pi * (d.dayofyear - 200) / 365)
    price = 0.9 * price + 0.1 * 2000 * season + rng.gauss(0, 60)
    modal = round(price, -1)
    if rng.random() < 0.003:                          # data-entry slip: an extra zero
        modal *= 10
    rows.append({"arrival_date": d, "modal_price": modal})
df = pd.DataFrame(rows)

# 1. Flag entry errors against a centred rolling median
med = df["modal_price"].rolling(15, center=True, min_periods=5).median()
df["suspect"] = (df["modal_price"] > 3 * med) | (df["modal_price"] < med / 3)
print("suspect rows:", int(df["suspect"].sum()))
raw_ret = (df["modal_price"] / df["modal_price"].shift()).apply(math.log)
print("daily vol, raw rows (%):", round(100 * raw_ret.std(), 2))
clean = df[~df["suspect"]].copy()

# 2. Log returns only across short gaps
clean["gap_days"] = clean["arrival_date"].diff().dt.days
clean["log_ret"] = (clean["modal_price"] / clean["modal_price"].shift()).apply(math.log)
clean.loc[clean["gap_days"] > 3, "log_ret"] = float("nan")
print("daily vol, cleaned (%):", round(100 * clean["log_ret"].std(), 2))
print("returns dropped for long gaps:", int((clean["gap_days"] > 3).sum()))

# 3. Rolling volatility: std of daily log returns over 30 trading days, in %
clean["vol_30d_pct"] = 100 * clean["log_ret"].rolling(30, min_periods=20).std()

# 4. Seasonal band from 2024-2025 by calendar month
clean["month"] = clean["arrival_date"].dt.month
hist = clean[clean["arrival_date"] < "2026-01-01"]
band = hist.groupby("month")["modal_price"].quantile([0.25, 0.75]).unstack()
band.columns = ["p25", "p75"]
cur = (clean[clean["arrival_date"] >= "2026-01-01"]
       .groupby("month")
       .agg(median_2026=("modal_price", "median"), vol_pct=("vol_30d_pct", "mean")))
out = cur.join(band)
out["position"] = out.apply(lambda r: "above band" if r.median_2026 > r.p75
                            else ("below band" if r.median_2026 < r.p25 else "within"), axis=1)
print(out.round(1).to_string())
text
suspect rows: 1
daily vol, raw rows (%): 11.88
daily vol, cleaned (%): 3.39
returns dropped for long gaps: 1
       median_2026  vol_pct     p25     p75    position
month
1           2140.0      2.1  1975.0  2100.0  above band
2           1820.0      2.3  1717.5  1840.0      within
3           1470.0      3.6  1457.5  1622.5      within
4           1320.0      3.9  1300.0  1400.0      within
5           1200.0      4.8  1190.0  1390.0      within
6           1590.0      4.6  1407.5  1540.0  above band
7           1760.0      3.4  1750.0  1930.0      within
8           2200.0      2.7  1962.5  2240.0      within
9           2340.0      2.6  2470.0  2680.0  below band

Reading the output

One typo more than tripled the volatility. With the single ten-times entry left in, daily volatility over the whole series is 11.88%; with it removed, 3.39%. The slip creates two huge returns, one up and one down. A 15-day centred median with a 3× threshold catches it without touching genuine spikes, which build over several days. Check each flagged row by eye before dropping it; a real crash can also look like this.

Long gaps are not one-day moves. A return from a Saturday to a Tuesday after a closure spans several days of information. Here only one gap exceeded three days, but around festival closures there can be many. Setting those returns to missing keeps the measure a daily one.

Volatility is higher when prices are low. The same rupee-sized shocks are a larger percentage of a ₹1,200 price in May than of a ₹2,200 price in August, so May shows 4.8% against 2.7%. Log returns measure proportional risk, which is what a buyer's margin feels; if the decision-maker thinks in rupees, also show the rupee standard deviation.

"Unusual" depends on the comparison. September 2026 at ₹2,340 is the highest monthly median of 2026, which might tempt someone to call it a spike. Against the September band from the two earlier years (₹2,470 to ₹2,680) it is actually below the usual range. January and June are the months that sat above their bands.

Traps specific to mandi data

  • Variety mix. If a premium variety stops arriving, the modal price falls without any market move. Filter to one variety or report the mix beside the price.
  • Thin days. A modal price from a day with a tiny arrival is less informative. Weight by arrivals, or drop days below a minimum arrival you state.
  • Market coverage changes. A market that begins reporting mid-year changes any multi-market average. Build panels only from markets that report throughout.
  • Two years is a short history. A seasonal band from two years is a rough guide; one unusual year moves it a lot. Say how many years it uses.

Presenting it to a procurement head

Two numbers per commodity–market, updated weekly: where the current price sits against its seasonal band, and the recent 30-day volatility. Then turn volatility into a rupee range they can plan with: at 2.6% daily volatility on ₹2,340, one standard deviation over roughly 20 trading days is about ₹2,340 × 0.026 × √20, around ₹270. Present that as a scale of possible movement, not a forecast; the data describes the past only.

Time-series work of this kind, with honest handling of gaps and outliers, is part of the Data Science programme. If you are new to Python, the free skill check shows where to begin.

Frequently asked questions

Which price should I use from mandi data, modal, minimum or maximum?

Use the modal price for trend and volatility work, since it is the price at which most of the day's arrivals traded. Keep minimum and maximum for a separate intra-day dispersion measure, because they are driven by the extremes of quality on that day.

How do I measure agricultural price volatility?

A standard approach is the standard deviation of daily log returns of the modal price over a rolling window, such as 30 trading days. Compute returns only between consecutive trading days with a short gap, and remove obvious data-entry errors first, or a single typo will dominate the result.

PK
Pankit Kumar
Lead Instructor, NeuraPath Academy

Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.

This article is part of our Data Science programme — 6 months. From data foundations to machine learning, deep learning and deployment.

Explore Data Science
Counselling is free · no obligation

Not sure which programme fits?

Tell us your background and we will map it to the right entry point — including saying so when a cheaper programme is the better fit. A counsellor replies within one working day.