Portfolio
Case study 02 / IPO Listing Gain

Case study 02 · Machine learning & markets

IPO Listing Gain Predictor

How much does an Indian mainboard IPO gain or lose on listing day? The model uses only what is knowable before it lists, grey market premium, day-wise subscription, and the mood of the Nifty 50. But the real finding isn't the model. It's that a random train/test split scores a flattering R² ≈ 0.55 and is quietly lying to you, because the IPO market cooled sharply after 2024.

Role
#built_from_scratch
Dataset
310+ IPOs, scraped
Validation
Walk-forward, out-of-time
Status
Deployed on Render
310+
IPOs scraped, 2022 → 2026
0.50
Honest walk-forward R²
11pp
Mean absolute error
126
Out-of-time test IPOs
9
Retail-visible features

01 · How it works

Predicting listing-day gain from what you can see before you apply

In India retail IPO decisions run on grey market premium. GMP is an unofficial number, and applicants read it as a forecast: GMP says +40%, so expect +40%. On out-of-time data that turns out to be half right. GMP correlates 0.52 with the actual listing gain, so it knows the direction, but scored as a prediction it gets R² ≈ 0.00. It is badly wrong about how far. Closing that gap is the whole job of the model.

01

The dataset didn't exist

No file anywhere has this. I scraped it across several sources: IPO fundamentals, listing sheets and five years of daily Nifty closes, which cleaned down to 309 usable IPOs.

02

Nine features, all visible before you apply

Grey market premium over three days, day-wise subscription, and how the Nifty is behaving. The same IPO lists very differently into a rally than into a correction, so market mood is a feature, not noise.

03

Walk-forward validation

Meaning the model is only ever trained on IPOs that listed before the one it is predicting, which is how it would work live. Scored this way it gets R² 0.50, and that is the number the app reports.

04

Two shallow models averaged

Gradient boosting plus random forest, both kept deliberately simple. 309 rows is a small dataset and complex models memorise small datasets instead of learning from them. Averaging the two was steadier across folds than either alone.

The full pipeline

Scrape, engineer features, validate, train, deploy. Five notebooks run in order, then one artifact ships to the API.

Web scraping, multiple sources
IPO fundamentals · listing sheets · daily Nifty
Raw layer, 16 CSVs
One row per IPO (issue price, 5-day GMP, subscription, fundamentals, target), five year-wise listing sheets used only for listing dates, and five years of daily Nifty 50 closes.
notebook 01, load & stack
Exploratory analysis
What actually drives listing gains, run before any model, so feature choices came from the data rather than from assumption.
notebook 02
Feature engineering, 9 features
Each IPO is date-matched to its Nifty window, then gmp_trend sub_momentum and qib_vs_retail are derived. Date matching is the step everything depends on a wrong join here silently leaks future market data into a feature.
notebook 03 → ipo_features.csv
How do we score this?, the decision that mattered
Random split or walk-forward. Same data, same model, two very different numbers, and only one of them survives contact with a market you haven't seen.
notebook 04 · decision
Random split, rejected
R² 0.55, but the post-2024 regime leaks into training. Reported only as a cautionary baseline.
walk-forward only
Ensemble, GradientBoosting + RandomForest
Averaged predictions from deliberately shallow estimators, with the training target clipped per fold. Two models, two feature sets: a full model and a deployable retail model.
notebook 05 · scikit-learn
One artifact, models/model.joblib
Both estimators, the feature order, per-feature medians and ranges for the UI, the honest metrics, the sklearn version and a UTC training timestamp. Scored before the final fit so the accuracy shown to users never comes from data the production model trained on.
train_model.py
FastAPI service, 3 endpoints
/health returns the model timestamp, so the health check verifies a loaded model rather than a live process. /api/meta feeds the form. /api/predict validates with Pydantic, builds a one-row frame in the exact trained feature order, averages both estimators, and returns the gain with its error bar.
app/main.py
Live prediction UI

02 · Challenges

Where this got difficult

Challenge 01

My first score was flattering me

I did what you normally do: shuffle the 309 IPOs, hold out 20%, score it. R² 0.55. Presentable, and wrong.

About 60% of my data lists in 2025 to 26, after the IPO market cooled. Shuffling scatters those cooled-market IPOs across both the training and the test set, so the model gets to learn what the new market looks like from the very period I was testing it on. The jargon for it is regime leakage: not the answer leaking, but the era leaking. In production you never have that, you always predict forward into a market you have not seen.

So I switched to walk-forward validation and the score dropped to 0.50. Lower, and the only one worth quoting. The random-split number stays on the page as a warning, not as a result.

Challenge 02

The same mistake, wearing a different hat

A few listings gain +90% and those outliers drag the model towards them, so the training target needs clipping at the 2nd and 98th percentile. I nearly clipped across the whole dataset, which would have quietly leaked the test period's distribution into training. Same error as the random split, harder to spot. It has to be clipped inside each fold, using only that fold's own training data.

Challenge 03

A model you can't act on isn't useful

The accurate version uses the final subscription book. That number only closes after bidding ends, which is after you have already applied, so it is fine for a paper and useless for a decision.

I rebuilt it using only what a retail applicant can see by bidding day 3. I expected to pay heavily for that and it cost almost nothing: R² 0.46 against 0.50. The deployed model is the day-3 one.

Challenge 04

One wrong join and the whole thing is fiction

Every IPO has to be matched to the Nifty window around its own listing date. Get that join wrong by a few rows and the model is reading market data from after the listing, which looks like brilliant accuracy and is completely fake. Nothing errors out when this happens, which is what makes it dangerous. It is the step I checked most.

03 · Limitations

What it can't do

The error bar is wide

MAE is about 11 percentage points. A prediction of "+18%" means "probably a decent listing, give or take a lot". The app shows the error bar next to the number, because a model that hides its uncertainty is worse than no model.

309 rows is small

That is the entire mainboard population for the window, not a sample I chose. But it caps how complex the model can usefully get.

Mainboard only

SME IPOs behave differently and are not in the dataset, so the model should not be pointed at them.

It goes stale

The market regime shifts, that is the whole lesson of the project. It needs retraining as new listings land, roughly weekly.

GMP is unofficial data

The strongest feature comes from an unregulated, unaudited source. If GMP reporting changes, the model degrades and there is no clean substitute.

Listing day only

It predicts the listing pop, nothing after it. It says nothing about whether the stock is worth holding.

Stated plainly, in the app itself: this is an educational model, not investment advice. Publishing the error bar next to the prediction is the point.

04 · What I learned

What I took away from building it

Test it the way you'll run it

If the model predicts forward in production, it has to be scored predicting forward. Any validation that doesn't match the deployment loop is measuring something you will never actually do.

A better score can be a worse result

0.55 felt like progress and was the number I had to throw away. I now distrust a score that goes up without me having done anything to earn it.

Leakage doesn't announce itself

Neither the shuffle nor the clipping threw an error. Both just returned a better number. The failures worth worrying about are the ones that look like success.

Timing decides whether a feature exists

The final subscription book is genuinely predictive and genuinely unusable, because it arrives after the decision. Asking when a feature becomes available changed the model more than any tuning did.

TRY A prediction

Enter the pre-listing signals, issue size, early subscription, three-day GMP, Nifty mood, and the deployed retail model returns a listing-day gain with its honest error bar.