On 15 October, foreign institutional investors (FIIs) were net sellers of Indian stocks for the 15th session in a row. Each evening the provisional figure came out red, and the same question followed: is the market about to fall?
I wanted to answer that with data instead of vibes, so I wrote a small backtest: find every long FII selling streak since 2014, and check what the Nifty 50 did afterwards. The whole thing is 58 lines of Python. This post walks through how it works and the three traps I hit. The findings, with charts and caveats, are in the companion piece on Medium (linked at the end).
The short answer: after day 10 of a selling streak, the Nifty did about what it does after any random day.
Not investment advice. I built the data API used here. Data runs from 1 January 2014 to 23 October 2026.
The data
Two daily series:
- FII/DII cash-market flows: net buying or selling by foreign and domestic institutions, in ₹ crore, from the exchanges' provisional figures.
- Nifty 50 daily levels: open, high, low, close.
I pulled both from the market data API I built, which serves them as paginated JSON. Each response carries meta.has_more and meta.next_cursor, so one helper fetches everything:
import os
import requests
import pandas as pd
API = "https://api.tapetide.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['TAPETIDE_API_TOKEN']}"}
def fetch_all(path, **params):
"""Follow next_cursor until the endpoint says there is nothing left."""
rows, params = [], {"limit": 1000, **params}
while True:
page = requests.get(f"{API}{path}", headers=HEADERS, params=params, timeout=30)
page.raise_for_status()
body = page.json()
rows += body["data"]
if not body["meta"]["has_more"]:
return pd.DataFrame(rows)
params["cursor"] = body["meta"]["next_cursor"]
flows = fetch_all("/market/institutional-flows", **{"from": "2014-01-01"})
nifty = fetch_all("/indices/nifty-50/levels", **{"from": "2014-01-01"})
from is a Python keyword, hence the **{"from": ...} unpacking. 1,000 is the maximum page size, and a bigger value gets a 400 rather than being silently clamped. 3,151 flow rows and 3,170 Nifty rows means four requests each, so the whole data step is eight HTTP calls.
The join
df = flows[["date", "fii_net_cr"]].merge(nifty[["date", "close"]], on="date")
df = df.sort_values("date").reset_index(drop=True)
An inner join on date gives 3,151 sessions that have both a flow figure and a Nifty close.
Trap 1: the join hides missing days. The index has 3,170 sessions since 2014, so 19 sessions have no flow figure. An inner join drops them without a word. That matters here, because a missing day in the middle of a selling run glues two streaks into one.
I checked with a set difference. Two of the long streaks (December 2014 and October to November 2018) span a missing day. Re-running on the Nifty calendar, nifty[["date", "close"]].merge(flows[["date", "fii_net_cr"]], on="date", how="left"), makes a missing flow NaN. NaN < 0 is False, so the gap counts as "not selling" and breaks the streak. That gave 47 streaks instead of 48 (the October 2018 one drops below 10 sessions) and moved the 3-month average from +3.38% to +3.17%. The conclusion survived, so I kept the simpler join and wrote the limitation down. Always run that check before you trust a streak count.
Counting streaks in three lines
This is the part I like:
selling = df["fii_net_cr"] < 0
streak_id = (~selling).cumsum()
df["streak_day"] = selling.groupby(streak_id).cumsum()
~selling is True on every non-selling day, and its cumulative sum goes up by one each time. So every run of selling days shares one streak_id, the id of the last buying day before it. Inside each group, a cumulative sum of the booleans counts 1, 2, 3 through the run, and resets to 0 on the next buying day.
Here it is on the latest streak:
| date | fii_net_cr | selling | streak_id | streak_day |
|---|---|---|---|---|
| 23 Sep 2026 | +1,617 | False | k | 0 |
| 24 Sep 2026 | −5,027 | True | k | 1 |
| 25 Sep 2026 | −3,694 | True | k | 2 |
| … | … | … | k | … |
| 8 Oct 2026 | −12,944 | True | k | 10 |
| … | … | … | k | … |
| 15 Oct 2026 | −5,285 | True | k | 15 |
| 16 Oct 2026 | +1,166 | False | k+1 | 0 |
No loops, and it works for any "consecutive days where X" question: up days, gap-ups, days above a moving average.
Forward returns
HORIZONS = {"1m": 21, "3m": 63, "6m": 126}
for label, n in HORIZONS.items():
df[label] = df["close"].shift(-n) / df["close"] - 1
signals = df[df["streak_day"] == 10]
21, 63 and 126 trading sessions are roughly one, three and six months.
Trap 2: shift(-n) looks into the future. That's exactly what you want for measuring outcomes. It must never feed into a signal. The last 126 rows have no 6-month return yet, and they stay NaN. Don't fillna(0) them: pandas' mean() already skips NaN, and filling with zero would pull every average toward zero.
There's a subtler version of the same trap. The FII figure is published after the close, so nobody can trade at day 10's close. Measuring from the next session instead is one more shift, df["close"].shift(-n - 1) / df["close"].shift(-1) - 1. It moves the 3-month average after day 10 from +3.38% to +3.48%, which doesn't change anything here. It's still worth doing every time. These are Nifty 50 price returns, so dividends are excluded from both columns.
The result
def summary(rows):
out = {h: f"{rows[h].mean():+.2%}" for h in HORIZONS}
out["3m up"] = f"{(rows['3m'].dropna() > 0).mean():.0%}"
out["n (3m)"] = rows["3m"].notna().sum()
return out
print(pd.DataFrame({"day 10 of a streak": summary(signals), "every day": summary(df)}))
day 10 of a streak every day
1m +0.75% +0.98%
3m +3.38% +3.07%
6m +5.27% +5.99%
3m up 66% 67%
n (3m) 47 3088
Forty-eight times since 2014, foreign investors sold for 10 sessions straight. The latest (day 10 was 8 October 2026) is too recent to score, so the averages cover 47 cases, and 46 at six months. Their next one, three and six months looked like any other stretch.
Is a 0.3-point gap anything?
Trap 3: comparing two averages without a sense of their noise. The 3-month returns after day 10 have a standard deviation of about 6.65 percentage points, so with 47 cases the standard error of their mean is close to 1 point. A random-subset check (draw 47 days at random from all days, many times) shows what chance alone produces:
import numpy as np
rng = np.random.default_rng(7)
all_3m = df["3m"].dropna().to_numpy()
means = np.array([rng.choice(all_3m, 47, replace=False).mean() for _ in range(20_000)])
print(np.percentile(means, [5, 95]), (means >= signals["3m"].mean()).mean())
Ninety percent of random 47-day sets average between +1.15% and +5.01%, and 40% of them beat the selling-streak average. A 0.3-point gap is well inside the noise. The flip side: with 47 cases, this can't rule out a real effect of a point or two in either direction. The windows also overlap in time, so treat this as a sanity check, not a formal test.
I also recomputed the headline numbers with a SQL query against the database behind the API. They matched to two decimals. That rules out bugs in the pandas logic, though not problems in the underlying data.
The streak table
The last few lines group the selling days by streak_id and keep runs of 10 or more:
streaks = (
df[selling]
.groupby(streak_id[selling])
.agg(start=("date", "first"), end=("date", "last"),
sessions=("date", "size"), sold_cr=("fii_net_cr", "sum"))
)
long_ones = streaks[streaks["sessions"] >= 10]
48 streaks of 10+ sessions since 2014
start end sessions sold_cr
2026-02-26 2026-04-09 27 -173187.48
2024-09-27 2024-11-22 38 -166394.27
2025-01-03 2025-02-03 23 -92384.16
2026-09-24 2026-10-15 15 -84138.07
2022-02-14 2022-03-15 21 -80736.80
2026-05-26 2026-06-12 13 -70986.85
2020-02-24 2020-03-26 23 -70132.90
2021-11-16 2021-12-27 29 -69052.80
In nominal rupees, five of the six biggest streaks started in September 2024 or later. There are too few recent cases yet to test whether they behave differently.
What I'd add next
- Size-weighted streaks. Right now a ₹10 crore selling day counts the same as a ₹10,000 crore one.
-
The domestic side. The flows endpoint also returns
dii_net_cr. In 8 of the 10 streaks since January 2025, domestic institutions bought more than foreigners sold. Whether unabsorbed streaks behave differently is the next question. - Other indices. Swap the slug in the levels URL and the rest of the script runs unchanged.
The full script is below. Set TAPETIDE_API_TOKEN, run it, and it prints both tables.
import os
import requests
import pandas as pd
API = "https://api.tapetide.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['TAPETIDE_API_TOKEN']}"}
def fetch_all(path, **params):
"""Follow next_cursor until the endpoint says there is nothing left."""
rows, params = [], {"limit": 1000, **params}
while True:
page = requests.get(f"{API}{path}", headers=HEADERS, params=params, timeout=30)
page.raise_for_status()
body = page.json()
rows += body["data"]
if not body["meta"]["has_more"]:
return pd.DataFrame(rows)
params["cursor"] = body["meta"]["next_cursor"]
flows = fetch_all("/market/institutional-flows", **{"from": "2014-01-01"})
nifty = fetch_all("/indices/nifty-50/levels", **{"from": "2014-01-01"})
df = flows[["date", "fii_net_cr"]].merge(nifty[["date", "close"]], on="date")
df = df.sort_values("date").reset_index(drop=True)
# A streak is a run of consecutive sessions where FIIs were net sellers.
selling = df["fii_net_cr"] < 0
streak_id = (~selling).cumsum()
df["streak_day"] = selling.groupby(streak_id).cumsum()
# Forward Nifty returns: roughly 1, 3 and 6 months of trading sessions.
HORIZONS = {"1m": 21, "3m": 63, "6m": 126}
for label, n in HORIZONS.items():
df[label] = df["close"].shift(-n) / df["close"] - 1
signals = df[df["streak_day"] == 10]
def summary(rows):
out = {h: f"{rows[h].mean():+.2%}" for h in HORIZONS}
out["3m up"] = f"{(rows['3m'].dropna() > 0).mean():.0%}"
out["n (3m)"] = rows["3m"].notna().sum()
return out
print(pd.DataFrame({"day 10 of a streak": summary(signals), "every day": summary(df)}))
streaks = (
df[selling]
.groupby(streak_id[selling])
.agg(start=("date", "first"), end=("date", "last"),
sessions=("date", "size"), sold_cr=("fii_net_cr", "sum"))
)
long_ones = streaks[streaks["sessions"] >= 10]
print(f"\n{len(long_ones)} streaks of 10+ sessions since 2014")
print(long_ones.sort_values("sold_cr").head(8).to_string(index=False))
The data came from Tapetide's market data API, which I built. The findings, charts and limitations are in the companion piece on Medium. Not investment advice.

Top comments (0)