Anchoring a data-driven estimate on consensus

Consensus, moved a fraction of the way toward the forecast

The customer’s question is never “what will revenue be?” — they already have consensus. It is “where is consensus wrong?” This page builds the number that answers it, and shows every step with real figures.

The worked example’s numbers are also a golden test, so this page and the library cannot drift apart.

Step 1 — the inputs, and when they may be used

Four inputs per company-quarter, each read as of the moment the number is published: the value whose known_at is the latest one strictly before that instant.

Input Cap
model at most 21 days old
consensus at most 60 days old
consensus 30 days earlier at most 60 days old
first reported actual evaluation only, never an input
Show the code
AS_OF = pd.Timestamp("2026-01-20", tz="UTC")
LOOKBACK = pd.Timedelta(days=30)

estimates = pf.validate_estimates(examples.example_panel())
examples.format_dates(estimates)
entity period target period_end source value known_at
0 ZS 2026Q1 revenue 2026-01-31 consensus 790 2025-12-01
1 ZS 2026Q1 revenue 2026-01-31 consensus 798.8 2026-01-15
2 ZS 2026Q1 revenue 2026-01-31 model 845.3 2026-01-12

Two consensus prints, one model run. The older consensus is what the 30-day revision measures against.

Step 2 — read it, reshape it, age it

Show the code
anchored = pf.latest_before(estimates, AS_OF)
subjects = pf.age_at(pf.pivot_sources(anchored), AS_OF)
revisions = pf.consensus_revision(estimates, AS_OF, lookback=LOOKBACK)
subjects = subjects.merge(revisions, on=pf.subject_index(subjects), how="left")
subjects[
    ["entity", "consensus", "model", "consensus_revision", "model_age", "consensus_age"]
]
entity consensus model consensus_revision model_age consensus_age
0 ZS 798.8 845.3 0.0111392 8 days 5 days

Five steps, every one explicit: anchor, reshape, age, measure the revision, join it on. A method never reaches past this pipeline to the raw panel.

Step 3 — the three ingredients

All three are relative quantities, so companies of any size are comparable.

With the model forecast \(m\), the consensus \(c\), the consensus \(c_{-30}\) read thirty days earlier, and the actual \(y\) (symbols as on the notation page):

\[ \Delta = \frac{m - c}{\lvert c\rvert} \qquad\text{the gap: what the data says consensus is missing} \]

\[ r = \frac{c}{c_{-30}} - 1 \qquad\text{the revision: what analysts have already started to do} \]

\[ s = \frac{y}{c} - 1 \qquad\text{the surprise: the target, known only afterwards} \]

The revision is two anchored reads of the same quantity, thirty days apart, which is why it lives in history and not in the method. A missing prior consensus comes back missing, never as a revision of zero — those are different facts.

Step 4 — fit the weights once, and freeze them

The published estimate is a forecast of the surprise from the gap and the revision:

\[ \hat s = a + b_\Delta\, \Delta + b_r\, r \]

The customary beat \(a\) is the surprise when the gap and the revision are both zero. The gap weight \(b_\Delta\) and the revision weight \(b_r\) say how much of each carries into the expected surprise \(\hat s\).

This is regression-based forecast combination (Granger and Ramanathan 1984), with the intercept capturing the customary beat (Richardson et al. 2004; Bartov et al. 2002).

Fitted by ordinary least squares on reports before a cut-off date, then never refitted inside the evaluation period. The library ships the fitted sets as data:

Show the code
pd.DataFrame(
    [
        pf.ANCHORING_2025_01.model_dump(mode="json"),
        pf.CORE_2025_01.model_dump(mode="json"),
    ]
).set_index("version").T
version anchoring-2025-01 core-2025-01
customary_beat 0.0112 0.0089
gap_weight 0.129 0.0442
revision_weight 1.28 1.2348
maximum_absolute_gap 0.25 0.25
maximum_prediction_age P21D P21D
maximum_consensus_age P60D P60D
fitted_through 2024-12-31 2024-12-31

Why \(b_\Delta\) is near 0.13 and not 1

This is the step that surprises people. Let \(\xi\) be the part of the surprise that is genuinely knowable in advance. The gap \(\Delta\) is a noisy reading of it, with noise \(\eta\), and the surprise \(s\) is \(\xi\) plus what nobody could have known, \(\zeta\):

\[ \Delta = \xi + \eta, \qquad s = \xi + \zeta \]

\[ b_\Delta = \frac{\operatorname{Cov}(s, \Delta)}{\operatorname{Var}(\Delta)} = \frac{\operatorname{Var}(\xi)}{\operatorname{Var}(\xi) + \operatorname{Var}(\eta)} \]

A signal-to-total-variance ratio, always between 0 and 1: the classical attenuation from measurement error (Fuller 1987). A weight of 0.129 does not mean the model is usually wrong. It means the gap’s noise variance \(\operatorname{Var}(\eta)\) is about seven times \(\operatorname{Var}(\xi)\), the variance of its predictive part.

Show the code
rng = np.random.default_rng(20260922)
size = 4000
knowable = rng.normal(0, 0.012, size)
reports = pd.DataFrame(
    {
        "gap": knowable + rng.normal(0, 0.012 * np.sqrt(6.75), size),
        "surprise": knowable + rng.normal(0, 0.020, size),
    }
)
slope = np.cov(reports.surprise, reports.gap)[0, 1] / reports.gap.var()
reports["quintile"] = pd.qcut(reports.gap, 5, labels=[1, 2, 3, 4, 5])

figure, axes = plt.subplots(1, 2, figsize=(11, 4))
sns.regplot(
    data=reports,
    x="gap",
    y="surprise",
    ax=axes[0],
    ci=None,
    scatter_kws={"s": 5, "alpha": 0.15, "color": examples.ACCENT, "edgecolor": "none"},
    line_kws={"color": examples.WARM, "label": f"fitted slope {slope:.3f}"},
)
grid = np.linspace(reports.gap.min(), reports.gap.max(), 50)
axes[0].plot(
    grid, grid, color=examples.GREY, ls="--", lw=1.2, label="publishing the model"
)
axes[0].set(xlabel=r"gap $\Delta$", ylabel="surprise $s$", title="One point per report")
axes[0].legend()

sns.barplot(
    data=reports.assign(beat=reports.surprise > 0),
    x="quintile",
    y="beat",
    ax=axes[1],
    color=examples.ACCENT,
    errorbar=None,
)
axes[1].axhline(0.5, color=examples.WARM, lw=1.2, ls="--", label="coin flip")
axes[1].set(
    xlabel="gap quintile",
    ylabel="beat rate",
    title="Share that beat consensus, by gap quintile",
)
axes[1].legend()
figure.tight_layout()
Figure 1: Simulated from the attenuation formula with the shipped noise ratio. The cloud is wide, the line is shallow, and the quintile sort is still monotone — which is the whole business case.

Why \(b_r\) is above 1

A coefficient above one looks wrong and is not. Analysts revise toward the truth but stop short, so the observed revision is a partial adjustment (Abarbanell and Bernard 1992; Gleason and Lee 2003). Revisions therefore predict later forecast errors (Coibion and Gorodnichenko 2015; Bouchaud et al. 2019). The regression recovers the structural weight divided by the share analysts complete. A weight of 1.28 implies they complete roughly three-quarters of the move before the report.

Step 5 — anchor

Do not publish the expected surprise \(\hat s\) as a level on its own. Multiply consensus \(c\) by it to get the anchored level \(\hat y\):

\[ \hat y = c \times (1 + \hat s) \]

Substituting the gap shows what anchoring really is:

\[ \hat y = \underbrace{c\,(1 + a + b_r r)}_{\text{consensus, corrected}} + \underbrace{b_\Delta\,(m - c)}_{\text{a fraction of the disagreement}} \]

Show the code
method = pf.AnchoredEstimate(forecast_source="model")
published = method.fit(subjects).apply(subjects)
row = published.iloc[0]

pd.DataFrame(
    [
        {"field": name, "value": f"{row[name]:+.4%}"}
        for name in [*pf.CONTRIBUTION_COLUMNS, pf.EXPECTED_SURPRISE_COLUMN]
    ]
    + [
        {"field": pf.ANCHORED_LEVEL_COLUMN, "value": f"{row.anchored_level:,.2f}"},
        {"field": pf.PARAMETERS_VERSION_COLUMN, "value": row.parameters_version},
    ]
)
field value
0 contribution_customary_beat +1.1200%
1 contribution_gap +0.7509%
2 contribution_revision +1.4258%
3 expected_surprise +3.2968%
4 anchored_level 825.13
5 parameters_version anchoring-2025-01

The three contributions sum to the expected surprise. That is what makes the number attributable: a customer sees which part of the adjustment is alternative data and which is consensus dynamics, and each data partner sees its own contribution.

Show the code
places = pd.DataFrame(
    [
        {"number": "consensus", "level": row.consensus},
        {"number": "anchored", "level": row.anchored_level},
        {"number": "model", "level": row.model},
    ]
)

figure, axis = plt.subplots(figsize=(9, 2.6))
axis.hlines(0, row.consensus - 8, row.model + 8, color=examples.GREY, lw=1.5, zorder=1)
sns.scatterplot(
    data=places,
    x="level",
    y=0,
    hue="number",
    s=160,
    ax=axis,
    zorder=3,
    palette=[examples.GREY, examples.ACCENT, examples.WARM],
)
for _, place in places.iterrows():
    axis.annotate(f"{place.level:.1f}", (place.level, -0.42), ha="center", fontsize=9)
axis.annotate(
    "",
    xy=(row.anchored_level, 0.14),
    xytext=(row.consensus, 0.14),
    arrowprops={"arrowstyle": "->", "color": examples.ACCENT, "lw": 1.4},
)
axis.annotate(
    f"{row.expected_surprise:.2%} of consensus",
    ((row.consensus + row.anchored_level) / 2, 0.2),
    color=examples.ACCENT,
    ha="center",
    fontsize=9,
)
axis.set(ylim=(-0.8, 0.55), yticks=[], ylabel="", xlabel="revenue ($m)")
axis.set_title("Where the published number sits")
sns.despine(ax=axis, left=True)
figure.tight_layout()
Figure 2: Where the published number sits, for the worked example.

The published number inherits the accuracy of consensus, and every bit of the alternative data sits in the difference.

Step 6 — publish nothing rather than a fallback

A feed that fills a gap with consensus is publishing “no information” as if it were a forecast. A feed that drops the row silently makes the gap invisible. So the row stays, the numbers go missing, and the reason travels with it.

Show the code
cases = pd.DataFrame(
    {
        "entity": ["fine", "no model", "stale model", "stale consensus", "wide gap"],
        "period": ["2026Q1"] * 5,
        "target": ["revenue"] * 5,
        "period_end": pd.to_datetime(["2026-01-31"] * 5, utc=True),
        "consensus": [798.8] * 5,
        "model": [845.3, float("nan"), 845.3, 845.3, 1100.0],
        "consensus_revision": [0.0] * 5,
        "model_age": pd.to_timedelta([8, 8, 22, 8, 8], unit="D"),
        "consensus_age": pd.to_timedelta([5, 5, 5, 61, 5], unit="D"),
    }
)
method.fit(cases).apply(cases)[
    [
        "entity",
        pf.ELIGIBLE_COLUMN,
        pf.ELIGIBILITY_REASON_COLUMN,
        pf.EXPECTED_SURPRISE_COLUMN,
        pf.ANCHORED_LEVEL_COLUMN,
    ]
]
entity eligible eligibility_reason expected_surprise anchored_level
0 fine True publishable 0.0187094 813.745
1 no model False missing_input NaN NaN
2 stale model False stale_prediction NaN NaN
3 stale consensus False stale_consensus NaN NaN
4 wide gap False gap_too_wide NaN NaN

The check order is part of the contract: missing input, then stale model, then stale consensus, then wide gap. A subject that is both stale and wide-gapped reports the staleness, because that is the fault to fix.

An absolute gap at or above the shipped 25% limit is almost always a mismatched fiscal period or a restatement rather than information, which is why it is a data break and not a very strong signal.

Step 7 — version every value

Fitted coefficients become constants shipped with the signal, re-estimated on a schedule and never tuned between releases. A consumer who backtests your history needs to know which coefficient set produced each published value.

Show the code
pd.DataFrame(
    [
        {
            "version": parameters.version,
            "fitted_through": str(parameters.fitted_through),
            "expected_surprise": f"{out.expected_surprise:+.4%}",
            "anchored_level": f"{out.anchored_level:,.2f}",
        }
        for parameters in (pf.ANCHORING_2025_01, pf.CORE_2025_01)
        for out in [
            pf.AnchoredEstimate(forecast_source="model", parameters=parameters)
            .fit(subjects)
            .apply(subjects)
            .iloc[0]
        ]
    ]
)
version fitted_through expected_surprise anchored_level
0 anchoring-2025-01 2024-12-31 +3.2968% 825.13
1 core-2025-01 2024-12-31 +2.5228% 818.95

Same inputs, different published number, and the difference is explainable rather than mysterious. The record a run writes down carries both:

Show the code
method.fit(subjects).to_dict()
{'method': 'anchored.estimate',
 'forecast_source': 'model',
 'consensus_source': 'consensus',
 'parameters_version': 'anchoring-2025-01',
 'fitted_through': '2024-12-31',
 'parameters': {'customary_beat': 0.0112,
  'gap_weight': 0.129,
  'revision_weight': 1.28,
  'maximum_absolute_gap': 0.25,
  'maximum_prediction_age': 'P21D',
  'maximum_consensus_age': 'P60D',
  'version': 'anchoring-2025-01',
  'fitted_through': '2024-12-31'}}

Plain values only. It serialises with nothing more than a JSON dump, and no pickle stands between a published number and its explanation.

What to take away

  • The arithmetic is ten lines. Everything that makes it a product is around it.
  • Point-in-time is a property of the read, not the formula.
  • The gap weight is an attenuation ratio. 0.129 says the gap is noisy, not that the model is wrong.
  • Publish nothing rather than a fallback, and emit the reason.
  • A parameter set is data with a version.

Next: the Bayesian posterior, which reaches a similar number by a different route and carries an uncertainty band.

References

Abarbanell, Jeffery S., and Victor L. Bernard. 1992. “Tests of Analysts’ Overreaction/Underreaction to Earnings Information as an Explanation for Anomalous Stock Price Behavior.” The Journal of Finance 47 (3): 1181–207. https://doi.org/10.1111/j.1540-6261.1992.tb04010.x.
Bartov, Eli, Dan Givoly, and Carla Hayn. 2002. “The Rewards to Meeting or Beating Earnings Expectations.” Journal of Accounting and Economics 33 (2): 173–204. https://doi.org/10.1016/S0165-4101(02)00045-9.
Bouchaud, Jean-Philippe, Philipp Krüger, Augustin Landier, and David Thesmar. 2019. “Sticky Expectations and the Profitability Anomaly.” The Journal of Finance 74 (2): 639–74. https://doi.org/10.1111/jofi.12734.
Coibion, Olivier, and Yuriy Gorodnichenko. 2015. “Information Rigidity and the Expectations Formation Process: A Simple Framework and New Facts.” American Economic Review 105 (8): 2644–78. https://doi.org/10.1257/aer.20110306.
Fuller, Wayne A. 1987. Measurement Error Models. Wiley. https://doi.org/10.1002/9780470316665.
Gleason, Cristi A., and Charles M. C. Lee. 2003. “Analyst Forecast Revisions and Market Price Discovery.” The Accounting Review 78 (1): 193–225. https://doi.org/10.2308/accr.2003.78.1.193.
Granger, Clive W. J., and Ramu Ramanathan. 1984. “Improved Methods of Combining Forecasts.” Journal of Forecasting 3 (2): 197–204. https://doi.org/10.1002/for.3980030207.
Richardson, Scott, Siew Hong Teoh, and Peter D. Wysocki. 2004. “The Walk-down to Beatable Analyst Forecasts: The Role of Equity Issuance and Insider Trading Incentives.” Contemporary Accounting Research 21 (4): 885–924. https://doi.org/10.1506/KHNW-PJYL-ADUB-0RP6.