Long-run performance of IPOs in India
The hypothesis, sample, tests and verdict rule, committed before any data was read. This is the file as it stands in the repository at research/papers/ipo_long_run/card.toml; it was frozen at commit ab21ddf3a8.
# Pre-registration card: long-run IPO performance in India (CALIBRATION CASE)
#
# This card transcribes docs/research/ipo_spec.md (pre-registered 2026-10-01,
# before any return was computed) into the bench's machine-readable form. The
# study has already been run by the pipeline (pipeline/tipsheet/compute/ipo.py),
# so here the bench is being calibrated: it must reproduce the published
# numbers in [calibration] from the same data, through its own stages.
#
# Rule: the card is committed to git before the data-access stage runs; the
# runner refuses an uncommitted or modified card and records its sha256 and
# commit in every run manifest. Changes after that are amendments (bottom).
[paper]
id = "ipo_long_run"
title = "Long-run performance of IPOs in India"
status = "calibration" # calibration | preregistered | running | complete | retired
original = "Ritter, Jay R. (1991). The Long-Run Performance of Initial Public Offerings. Journal of Finance 46(1), 3-27. doi:10.1111/j.1540-6261.1991.tb03743.x"
related = [
"Loughran, Tim and Jay R. Ritter (1995). The New Issues Puzzle. Journal of Finance 50(1), 23-51.",
"Ritter, Jay R. and Ivo Welch (2002). A Review of IPO Activity, Pricing, and Allocations. Journal of Finance 57(4), 1795-1828.",
]
original_claim = "US IPOs from 1975-84 underperformed size- and industry-matched firms over three years after listing (3-year wealth relative 0.83); firms that went public in high-volume years did worst."
# Numbers from the original paper that the post may quote (context, not results).
original_numbers = { us_sample_start = 1975, us_sample_end = 1984, us_issues = 1526, us_wealth_relative_3y = 0.83 }
spec_doc = "docs/research/ipo_spec.md"
preregistered_on = "2026-10-01"
[hypotheses]
H1 = "Mainboard IPOs in months 1-36 after listing earn a calendar-time alpha against Nifty 500 (price) different from zero. Primary: the CAPM intercept with Newey-West (3 lags) t-statistic."
H2 = "Listing-day gain is negatively related to the 1-year BHAR against Nifty 500 (Spearman, across issues)."
H3 = "Issues listed in hot months have a lower mean 1-year BHAR against Nifty 500 than the rest (Welch t, issue level; also month level)."
direction_expected = { H1 = "negative", H2 = "negative", H3 = "negative" }
[multiple_testing]
primary_tests = ["H1", "H2", "H3"]
method = "bonferroni"
alpha = 0.05
threshold = 0.0167
note = "p-values use the normal approximation to t (samples are 100+ months or 300+ issues)."
[universe]
description = "NSE mainboard IPOs (security type EQ or BE) listed from 2016-01-01 to the latest bhavcopy session; SME reported separately and descriptively only. Fresh listings only (ISIN never traded before); FPO, rights, partly paid, BSE-first listings excluded. Delisted stocks stay in."
survivorship = "Universe comes from NSE's issue list, not today's listed stocks."
start = "2016-01-01"
[data]
builder = "ipo_long_run"
inputs = """
listings.parquet canon (str id), symbol, board ('mainboard'|'sme'), listing_date (the listing session),
issue_price, list_close (raw listing-day close), list_adj_close (adjusted listing-day close).
prices.parquet d (session date), canon, adj_close (adjusted close; price return; no dividends). Only days traded.
calendar.parquet d: every NSE session, sorted.
bench.parquet d, nifty500, nifty500_ew, smallcap250 (price indices), nifty500_tri (total return). NaN where absent.
cash.parquet d, cash: 91-day T-bill total return index level.
"""
[parameters] # every constant the method uses; prose may cite these
horizons_sessions = { "1m" = 21, "6m" = 126, "1y" = 250, "3y" = 750, "5y" = 1250 }
horizons_years = [1, 3, 5]
cash_tbill_days = 91
primary_test_count = 3
ct_months_from = 1
ct_months_to = 36
ct_min_stocks = 10
nw_lags = 3
heat_window_months = 3
heat_burn_in_months = 12
hot_threshold = 0.6667
tercile_count = 3
[method]
returns = """
Horizon returns, per issue: p0 = the issue's adjusted close on its listing session. For horizon n sessions,
the target session is calendar[pos + n] where pos is the listing session's position in the NSE calendar. The
horizon exists only if pos + n < number of sessions in the calendar (else skip the issue for that horizon).
The end price is the issue's last adjusted close on or before the target; the end date is that close's date.
ret = p_end / p0 - 1. Benchmark return over the same dates: index close on or before the listing date to
index close on or before the end date. BHAR = ret - bench_ret. exited_early = the stock's last ever trade is
before the target AND more than 30 calendar days before the calendar's last session.
"""
summary = """
Per board and horizon: n, mean and median BHAR, share with BHAR > 0, wealth relative =
(1 + mean ret) / (1 + mean bench ret), number exited early.
"""
h1 = """
Calendar-time portfolio, mainboard only. For each stock take the last adjusted close in every calendar month it
traded. A month's return = that close / previous traded month's close - 1, valid only if the previous traded
month is the immediately preceding calendar month. Age = calendar month minus listing month; keep ages 1..36.
Portfolio return = equal-weighted mean across stocks in the month; stocks = their count. Benchmark monthly
returns: last daily close of the month, percentage change month on month (bench table; cash likewise from the
cash table). Keep months with stocks >= 10 and non-missing portfolio, nifty500 and cash returns.
For each benchmark b in nifty500, nifty500_ew, smallcap250, nifty500_tri: diff = port - b; t_excess = mean/
(sd(ddof=1)/sqrt(n)). OLS of (port - cash) on [1, (b - cash)], Newey-West SE with Bartlett weights 1 - l/(L+1),
L = 3, no small-sample correction. alpha_annualised = (1 + alpha)^12 - 1. Report first/last month, months,
median stocks.
"""
h2 = """
Mainboard issues with a 1y horizon. Listing gain = list_close / issue_price - 1. Spearman = Pearson correlation
of average ranks. t = rho * sqrt((n-2)/(1-rho^2)); two-sided normal p. Terciles: rank listing gain with ties
broken by order of appearance (issues sorted by listing_date then canon), split into three equal-count groups
(pandas qcut on ranks); report mean and median 1y BHAR per tercile.
"""
h3 = """
Heat table: one row per calendar month e from 2016-01 to the last session's month. Window = mainboard listings in
months e-2..e: window_listings (count) and window_median_gain (median listing gain; NaN if none). The first two
rows (incomplete windows) are NaN. Expanding percentile of each column: for row i, the share of earlier
non-missing values that are <= the current value; NaN for the first 14 rows (12 burn-in + 2) or if the value
is missing. pct_gain is NaN when window_listings is 0. heat = mean of available percentiles; NaN if pct_count
is NaN. hot = heat >= 2/3. An issue listing in month m takes the heat of row m-1. Compare mainboard 1y and 3y
BHAR (Nifty 500) of hot vs non-hot issues (drop issues whose label is NaN): Welch t, normal p. Month level:
average BHAR per (listing month, hot) then the same Welch test across months.
"""
costs_and_taxes = "Not applicable: buy-and-hold returns of a descriptive study; no trading strategy is claimed."
[outputs]
results = "results.json with {'facts': {key: number}, 'labels': {key: string}} using exactly the keys in [facts]."
series = ["ct_portfolio: month (month-end date), ret, stocks, nifty500, cash", "heat: month (month-end date), window_listings, window_median_gain, heat, hot"]
# Every reported number: unit and sanity range. Units: fraction (0.05 = 5%), t, p, count, ratio.
# The range check is a deterministic critic (wrong units show up as values outside the range).
[facts]
"listings.mainboard_n" = { unit = "count", range = [100, 2000] }
"listings.sme_n" = { unit = "count", range = [100, 3000] }
"h1.months" = { unit = "count", range = [24, 400] }
"h1.median_stocks" = { unit = "count", range = [10, 1000] }
"h1.nifty500.mean_monthly_excess" = { unit = "fraction", range = [-0.1, 0.1] }
"h1.nifty500.t_excess" = { unit = "t", range = [-10, 10] }
"h1.nifty500.alpha_annualised" = { unit = "fraction", range = [-1, 2] }
"h1.nifty500.t_alpha_nw" = { unit = "t", range = [-10, 10] }
"h1.nifty500.p_alpha_nw" = { unit = "p", range = [0, 1] }
"h1.nifty500.beta" = { unit = "ratio", range = [0, 3] }
"h1.nifty500_ew.mean_monthly_excess" = { unit = "fraction", range = [-0.1, 0.1] }
"h1.nifty500_ew.t_excess" = { unit = "t", range = [-10, 10] }
"h1.nifty500_ew.alpha_annualised" = { unit = "fraction", range = [-1, 2] }
"h1.nifty500_ew.t_alpha_nw" = { unit = "t", range = [-10, 10] }
"h1.nifty500_ew.beta" = { unit = "ratio", range = [0, 3] }
"h1.smallcap250.mean_monthly_excess" = { unit = "fraction", range = [-0.1, 0.1] }
"h1.smallcap250.t_excess" = { unit = "t", range = [-10, 10] }
"h1.smallcap250.alpha_annualised" = { unit = "fraction", range = [-1, 2] }
"h1.smallcap250.t_alpha_nw" = { unit = "t", range = [-10, 10] }
"h1.smallcap250.beta" = { unit = "ratio", range = [0, 3] }
"h1.nifty500_tri.mean_monthly_excess" = { unit = "fraction", range = [-0.1, 0.1] }
"h1.nifty500_tri.t_excess" = { unit = "t", range = [-10, 10] }
"h1.nifty500_tri.alpha_annualised" = { unit = "fraction", range = [-1, 2] }
"h1.nifty500_tri.t_alpha_nw" = { unit = "t", range = [-10, 10] }
"h1.nifty500_tri.beta" = { unit = "ratio", range = [0, 3] }
"bhar.1y.n" = { unit = "count", range = [30, 5000] }
"bhar.1y.mean_bhar_nifty500" = { unit = "fraction", range = [-1, 5] }
"bhar.1y.median_bhar_nifty500" = { unit = "fraction", range = [-1, 5] }
"bhar.1y.share_beat_nifty500" = { unit = "fraction", range = [0, 1] }
"bhar.1y.wealth_relative_nifty500" = { unit = "ratio", range = [0.2, 5] }
"bhar.1y.mean_bhar_smallcap250" = { unit = "fraction", range = [-1, 5] }
"bhar.1y.exited_early" = { unit = "count", range = [0, 5000] }
"bhar.3y.n" = { unit = "count", range = [30, 5000] }
"bhar.3y.mean_bhar_nifty500" = { unit = "fraction", range = [-1, 10] }
"bhar.3y.median_bhar_nifty500" = { unit = "fraction", range = [-1, 10] }
"bhar.3y.share_beat_nifty500" = { unit = "fraction", range = [0, 1] }
"bhar.3y.wealth_relative_nifty500" = { unit = "ratio", range = [0.2, 10] }
"bhar.3y.mean_bhar_smallcap250" = { unit = "fraction", range = [-1, 10] }
"bhar.3y.exited_early" = { unit = "count", range = [0, 5000] }
"bhar.5y.n" = { unit = "count", range = [30, 5000] }
"bhar.5y.mean_bhar_nifty500" = { unit = "fraction", range = [-1, 20] }
"bhar.5y.median_bhar_nifty500" = { unit = "fraction", range = [-1, 20] }
"bhar.5y.share_beat_nifty500" = { unit = "fraction", range = [0, 1] }
"bhar.5y.wealth_relative_nifty500" = { unit = "ratio", range = [0.2, 20] }
"bhar.5y.mean_bhar_smallcap250" = { unit = "fraction", range = [-1, 20] }
"bhar.5y.exited_early" = { unit = "count", range = [0, 5000] }
"h2.n" = { unit = "count", range = [30, 5000] }
"h2.spearman" = { unit = "ratio", range = [-1, 1] }
"h2.p" = { unit = "p", range = [0, 1] }
"h2.tercile.low.mean_bhar_1y" = { unit = "fraction", range = [-1, 5] }
"h2.tercile.mid.mean_bhar_1y" = { unit = "fraction", range = [-1, 5] }
"h2.tercile.high.mean_bhar_1y" = { unit = "fraction", range = [-1, 5] }
"h2.tercile.low.median_bhar_1y" = { unit = "fraction", range = [-1, 5] }
"h2.tercile.mid.median_bhar_1y" = { unit = "fraction", range = [-1, 5] }
"h2.tercile.high.median_bhar_1y" = { unit = "fraction", range = [-1, 5] }
"h3.1y.issues.n_hot" = { unit = "count", range = [1, 5000] }
"h3.1y.issues.n_rest" = { unit = "count", range = [1, 5000] }
"h3.1y.issues.mean_hot" = { unit = "fraction", range = [-1, 5] }
"h3.1y.issues.mean_rest" = { unit = "fraction", range = [-1, 5] }
"h3.1y.issues.diff" = { unit = "fraction", range = [-2, 2] }
"h3.1y.issues.t" = { unit = "t", range = [-10, 10] }
"h3.1y.issues.p" = { unit = "p", range = [0, 1] }
"h3.1y.months.n_hot" = { unit = "count", range = [1, 500] }
"h3.1y.months.n_rest" = { unit = "count", range = [1, 500] }
"h3.1y.months.diff" = { unit = "fraction", range = [-2, 2] }
"h3.1y.months.t" = { unit = "t", range = [-10, 10] }
"h3.1y.months.p" = { unit = "p", range = [0, 1] }
"h3.3y.issues.diff" = { unit = "fraction", range = [-5, 5] }
"h3.3y.issues.p" = { unit = "p", range = [0, 1] }
"h3.3y.months.diff" = { unit = "fraction", range = [-5, 5] }
"h3.3y.months.p" = { unit = "p", range = [0, 1] }
[labels]
"h1.first_month" = "YYYY-MM"
"h1.last_month" = "YYYY-MM"
[reconciliation]
abs_tol = 1e-9
rel_tol = 1e-7
count_exact = true
[lookahead_probe]
# The implementation is re-run on inputs cut at each date; every series row dated on or before
# the cut must be identical to the full run. A signal that peeks at later data fails here.
cuts = ["2021-12-31"]
series = { ct_portfolio = { key = "month", compare = ["ret", "stocks"] }, heat = { key = "month", compare = ["heat", "hot"] } }
# Published results (docs/research/ipo_spec.md, results log 2026-10-01). The bench must reproduce each value
# at the precision it was published. scale: 100 means the published figure is a percentage (or points).
[calibration]
# 2026-10-01 (calibration note, moved here from [amendments] so implementers never see it): the published
# 5-year wealth relative (1.28) is marked as an erratum after implementation A, implementation B and the
# pipeline's own ipo_summary table all gave 1.2749. The target is left as published so the mismatch stays visible.
"listings.mainboard_n" = { value = 529, dp = 0 }
"listings.sme_n" = { value = 728, dp = 0 }
"h1.months" = { value = 124, dp = 0 }
"h1.median_stocks" = { value = 79, dp = 0 }
"h1.nifty500.mean_monthly_excess" = { value = 0.47, dp = 2, scale = 100 }
"h1.nifty500.t_excess" = { value = 1.27, dp = 2 }
"h1.nifty500.alpha_annualised" = { value = 4.1, dp = 1, scale = 100 }
"h1.nifty500.t_alpha_nw" = { value = 0.89, dp = 2 }
"h1.nifty500.beta" = { value = 1.22, dp = 2 }
"h1.nifty500_ew.mean_monthly_excess" = { value = 0.26, dp = 2, scale = 100 }
"h1.nifty500_ew.t_excess" = { value = 1.13, dp = 2 }
"h1.nifty500_ew.alpha_annualised" = { value = 2.4, dp = 1, scale = 100 }
"h1.nifty500_ew.t_alpha_nw" = { value = 0.77, dp = 2 }
"h1.nifty500_ew.beta" = { value = 1.07, dp = 2 }
"h1.smallcap250.mean_monthly_excess" = { value = 0.14, dp = 2, scale = 100 }
"h1.smallcap250.t_excess" = { value = 0.64, dp = 2 }
"h1.smallcap250.alpha_annualised" = { value = 1.9, dp = 1, scale = 100 }
"h1.smallcap250.t_alpha_nw" = { value = 0.63, dp = 2 }
"h1.smallcap250.beta" = { value = 0.98, dp = 2 }
"h1.nifty500_tri.mean_monthly_excess" = { value = 0.38, dp = 2, scale = 100 }
"h1.nifty500_tri.t_excess" = { value = 1.02, dp = 2 }
"h1.nifty500_tri.alpha_annualised" = { value = 2.7, dp = 1, scale = 100 }
"h1.nifty500_tri.t_alpha_nw" = { value = 0.59, dp = 2 }
"h1.nifty500_tri.beta" = { value = 1.22, dp = 2 }
"bhar.1y.n" = { value = 402, dp = 0 }
"bhar.1y.mean_bhar_nifty500" = { value = 6.5, dp = 1, scale = 100 }
"bhar.1y.median_bhar_nifty500" = { value = -7.6, dp = 1, scale = 100 }
"bhar.1y.share_beat_nifty500" = { value = 44, dp = 0, scale = 100 }
"bhar.1y.wealth_relative_nifty500" = { value = 1.06, dp = 2 }
"bhar.1y.mean_bhar_smallcap250" = { value = 4.4, dp = 1, scale = 100 }
"bhar.3y.n" = { value = 231, dp = 0 }
"bhar.3y.mean_bhar_nifty500" = { value = 18.9, dp = 1, scale = 100 }
"bhar.3y.median_bhar_nifty500" = { value = -28.2, dp = 1, scale = 100 }
"bhar.3y.share_beat_nifty500" = { value = 41, dp = 0, scale = 100 }
"bhar.3y.wealth_relative_nifty500" = { value = 1.13, dp = 2 }
"bhar.3y.mean_bhar_smallcap250" = { value = 5.4, dp = 1, scale = 100 }
"bhar.5y.n" = { value = 143, dp = 0 }
"bhar.5y.mean_bhar_nifty500" = { value = 51.9, dp = 1, scale = 100 }
"bhar.5y.median_bhar_nifty500" = { value = -29.2, dp = 1, scale = 100 }
"bhar.5y.share_beat_nifty500" = { value = 41, dp = 0, scale = 100 }
"bhar.5y.wealth_relative_nifty500" = { value = 1.28, dp = 2, erratum = "Pipeline table ipo_summary holds 1.274878, which rounds to 1.27; the results log in ipo_spec.md printed 1.28. Found by this calibration on 2026-10-01; correction requested in docs/status/research.md." }
"bhar.5y.mean_bhar_smallcap250" = { value = 30.6, dp = 1, scale = 100 }
"h2.n" = { value = 402, dp = 0 }
"h2.spearman" = { value = 0.003, dp = 3 }
"h2.p" = { value = 0.95, dp = 2 }
"h2.tercile.low.mean_bhar_1y" = { value = 2.6, dp = 1, scale = 100 }
"h2.tercile.mid.mean_bhar_1y" = { value = 10.5, dp = 1, scale = 100 }
"h2.tercile.high.mean_bhar_1y" = { value = 6.5, dp = 1, scale = 100 }
"h2.tercile.low.median_bhar_1y" = { value = -11.5, dp = 1, scale = 100 }
"h2.tercile.mid.median_bhar_1y" = { value = -1.0, dp = 1, scale = 100 }
"h2.tercile.high.median_bhar_1y" = { value = -7.4, dp = 1, scale = 100 }
"h3.1y.issues.n_hot" = { value = 221, dp = 0 }
"h3.1y.issues.n_rest" = { value = 154, dp = 0 }
"h3.1y.issues.mean_hot" = { value = 3.1, dp = 1, scale = 100 }
"h3.1y.issues.mean_rest" = { value = 10.1, dp = 1, scale = 100 }
"h3.1y.issues.diff" = { value = -7.0, dp = 1, scale = 100 }
"h3.1y.issues.t" = { value = -1.10, dp = 2 }
"h3.1y.issues.p" = { value = 0.27, dp = 2 }
"h3.1y.months.n_hot" = { value = 36, dp = 0 }
"h3.1y.months.n_rest" = { value = 46, dp = 0 }
"h3.1y.months.diff" = { value = -19.1, dp = 1, scale = 100 }
"h3.1y.months.t" = { value = -1.91, dp = 2 }
"h3.1y.months.p" = { value = 0.056, dp = 3 }
"h3.3y.issues.diff" = { value = 8.7, dp = 1, scale = 100 }
"h3.3y.issues.p" = { value = 0.69, dp = 2 }
"h3.3y.months.diff" = { value = -14.9, dp = 1, scale = 100 }
"h3.3y.months.p" = { value = 0.60, dp = 2 }
[verdict_rule]
# How the scoreboard verdict follows from the primary test, fixed before results.
replicates = "H1 alpha negative with p < threshold"
partially = "H1 alpha negative with threshold <= p < 0.05, or H1 null but H3 negative with p < threshold"
fails = "H1 alpha not negative, or p >= 0.05, and no secondary result meets the threshold"
[amendments]
# date = "what changed and why (before or after results)"