Day-0 Discrepancy Audit

Report: max_report.tex, §1 Data Cleaning, Figure 1. Source: forecasting-bias-ibes/max/ scripts and their saved outputs. Audited 2026-09-26.

Figure 1 shows
48.2%
Day-0 share in the plotted histogram (19,370 / 40,198). The data is EPS estimates, not price targets.
Text implies
36.5%
272,741 / 746,626. The denominator only contains Days 0–1, not Days 0–100.
Ground truth
≈12.8%
Day-0 share of 12-month price targets issued 0–100 days after the last earnings call (272,349 / 2,132,602).
Bottom line. The figure and the text come from two different datasets, and both are wrong for the claim they support. Figure 1 is a histogram of the raw I/B/E/S EPS estimate file. 7_forecast_anchor_distance_histogram.py picked it automatically because it was the largest parquet on disk. The "746,626 forecasts within 100 days" in the text is really a file already cut to Days {0, 1}. Rebuilt from the raw price-target file, Day 0 is about 12.8% of forecasts in the 0–100 day window. It is still the busiest single day, but it is not a third or half of the sample.

1What the report claims

The Figure 1 caption and the §1 text say the baseline sample is "12-month I/B/E/S forecasts … published within 100 days post-earnings (746,626 forecasts), of which 272,741 … were published specifically on Day 0". That gives a Day-0 share of 272,741 / 746,626 = 36.5%. The bar at Day 0 in the figure reaches 0.48. These two numbers cannot describe the same data.

2Reverse-engineered funnel

I matched every count in the report to a saved script output or a file row count. The funnel crosses between three different datasets without saying so.

Report stepCountWhere it actually comes fromStatus
Raw one-year price targets1,276,627Row count of ibes_nptgsum.parquet, the summary / consensus file (one row per ticker-month), not analyst-levelconsensus level
− recording errors, − unmapped CUSIPs−1,747 / −77,5681,276,627 − 1,747 − 77,568 = 1,197,312 = row count of cleaned_consensus_targets.parquet. Nothing downstream uses this file.dead end
"Within the first 100 days" (analyst-level)746,626tight_window_analysis_row_level.parquet, built from ibes_nptgdet (analyst detail). Its own report says Filter window: days_since_earnings in {0, 1}. It holds 281,535 Day-0 and 465,091 Day-1 rows and nothing else.mislabelled
(script 10) drop events with no 4-case match−26,269 → 720,357dataset_filtering_retention_report.txt. The report skips this step.omitted
Day 0 (trading-day matched)272,741Script 10: 37.86% of 720,357, or 36.5% of 746,626. Either way, this is Day 0 as a share of Days 0–1 only.count correct
Firm-date median consensus122,958consensus_with_base_prices_diagnostics_sp500.txtconsistent
S&P 500 universe (size rank 1–500)31,285Same diagnostics fileconsistent
≥3 analysts on Day 028,807 → 13,817consensus_with_signals_diagnostics.txt. The input is 28,807, not 31,285, so it comes from a different run.run mismatch

3Why the figure shows 48%

The figure file in the report, analyst_days_since_earnings_distribution.png, is written by max/7_forecast_anchor_distance_histogram.py. That script has no fixed input. Its find_full_window_dataset() function:

  1. searches the whole repository for *.parquet files and skips names containing tight, twoday, 2day, filtered or 0day. That rule excludes the real price-target files.
  2. keeps any file that has an earnings-date column (anndats_act) and a forecast-date column (anndats). The EPS detail files also have both.
  3. picks the file with the largest size on disk: ibes_prev_earning_date_with_day_gap.parquet (497 MB, 33.8M rows). This is ibes_ndet (I/B/E/S EPS estimates, fpi 1/2/6/7…, median value 0.68) with extra columns added.
selected = max(candidates, key=lambda f: f.stat().st_size)   # line 43: "the raw untruncated dataset"

In that file, anndats_act is the announcement date of the fiscal period being forecast, so 89.8% of the EPS estimates fall before it (negative gap). Only 40,198 of 33.8M rows land in 0–100, and 19,370 of those are on Day 0: 48.19%. Running the script's selection and binning logic reproduces the plotted 0.482 bar exactly. The hard-coded ax.set_ylim(0, 0.52) was set to fit that bar.

The script also has a guard that rejects the right data:

if pd.isna(max_day) or max_day <= 1:
    raise ValueError("... This file is still a 2-day truncated dataset ...")

The only price-target files available were the {0,1}-truncated ones. The file-name filter and this guard together pushed the script onto the one large file that passed, which was the wrong dataset. The script ran without errors and printed a path that nobody checked.

4Why the text's 36.5% is also wrong

10_filter_0day_after_earnings.py honestly labels its 0–100 window, but the input it filters (tight_window_analysis_row_level.parquet) contains only Days 0 and 1. The "0–100 window" step therefore removes nothing. The 746,626 / 720,357 denominators are "Day 0 + Day 1", and 272,741 / 720,357 = 37.86% is the Day-0 share of a two-day sample. The script's own plot (§6, right) shows this: it has exactly two bars. No script in the repo creates the {0,1} file. Its builder is missing, and max/ is not under git.

5Ground truth: rebuilt 0–100 day sample

Method. I started from ibes_nptgdet.parquet (analyst-level price targets), kept horizon == 12 (2,232,850 rows) and mapped CUSIP→PERMNO with cusip_permno.parquet (2,161,883 rows). I attached each target to the most recent earnings announcement on or before its date for the same ticker (merge_asof, backward), using I/B/E/S actuals (ibes_nact). Then gap = forecast date − earnings date, in calendar days.

Validation against the report's own file. My rebuild gives 726,995 rows at gap ∈ {0,1} (their file: 746,626) and 272,349 at Day 0 (their trading-matched count: 272,741). 97.0% of their rows appear in the rebuild with the same earnings date. Using ibes_ndet for the earnings dates instead gives 12.85%. The result does not depend on that choice.
Window 0–100 days after the last callForecastsShare
Day 0272,34912.77%
Day 1454,64621.32%
Days 2–1001,405,60765.91%
Total in window2,132,602100%
Outside window (>100 days, or before the first recorded call)29,281

If the rebuild is scaled up by the ~2.7% row gap to the report's file, the Day-0 share stays between 12.5% and 12.8%. The report's own downstream number, 272,741 Day-0 forecasts, is essentially right. What is wrong is the denominator: about 2.13M forecasts fall in the 0–100 window, not 746,626.

6The three distributions side by side

Report Figure 1
Figure 1 as it appears in the report (script 7). The Day-0 bar is 0.482. This is the EPS estimate file.
Script 10 plot
Script 10's own plot of the "0–100 day" window. It has only two bars, which shows the input was already truncated to Days {0,1}.

7Fixes

  1. Replace Figure 1 with a histogram of the rebuilt price-target sample. It should show a Day-0 bar of about 12.8% and a Day-1 bar of about 21.3%.
  2. Rewrite the §1 text: "≈2.13M analyst-level 12-month price targets were published within 100 days after an earnings call, of which 272,741 (≈12.8%) were published on Day 0."
  3. Script 7: remove find_full_window_dataset() and read one explicit, named input. Add assertions that the input is price-target data (for example, a price_target/forecast_price column is present).
  4. Recover or rewrite the missing builder of tight_window_analysis_row_level.parquet. Keep a 0–100 day intermediate file so the Day-0 filter runs on the real window.
  5. Fix the funnel: the 1,276,627 → 1,197,312 steps belong to the consensus summary file. Either drop them or restate the funnel on ibes_nptgdet (2,299,723 raw → 2,232,850 12-month → 2,161,883 mapped). Also report the 26,269-row unmatched-event drop, and rerun so 31,285 and 28,807 agree.

8Reproduce

The scripts are in /root/home/kurby/playground/max_error/repro/. Run them from forecasting-bias-ibes/ with pandas and pyarrow installed.

python repro/select_like_script7.py   # which parquet script 7 picks (EPS file)
python repro/share.py ibes_ndet.parquet max/outputs_max/price_target_pipeline/tight_window_analysis_row_level.parquet
                                     # 48.19% on the EPS file; the tight file only has days 0 and 1
python repro/rebuild.py              # ground-truth 0–100 day price-target sample: Day 0 = 12.77%