crucible / engine /bridge.py
s-ttp's picture
PM evidence ids from state; guidance candidate diagnostics; bridge wording
556be2e verified
Raw History Blame Contribute Delete
7.29 kB
"""The adjusted↔GAAP earnings bridge, measured like for like.
An adjusted EPS forecast becomes GAAP net income only through a bridge: how much larger the adjusted
figure is than GAAP diluted EPS for the same period and the same share basis. The earlier bridge was
one trailing point that divided adjusted EPS × *today's* share count by GAAP net income, mixing a
per-share series computed on historical weighted shares with the current count, and it was carried
forward as if it could never change.
Here the bridge is a series of like-for-like observations:
ratio(period) = Σ adjusted EPS over the period ÷ GAAP diluted EPS for the same period
using fiscal years (four provider quarters summed against the filed annual diluted EPS) and the
trailing twelve months. Both sides are per diluted share for the same period, so share-count
changes cancel. The bridge records the median, range, latest value and trend, and flags a probable
change in the adjustment methodology when the latest observation departs materially from its own
history. In that case the older observations are not carried forward: only the latest is used, with
a wider uncertainty band.
"""
from __future__ import annotations
import datetime as _dt
import statistics
import definitions as D
RATIO_BOUNDS = (0.5, 3.0) # outside this the two EPS series are not describing the same earnings
CHANGE_THRESHOLD = 0.20 # latest vs its own history: a methodology change is likely beyond this
MIN_UNCERTAINTY = 0.05 # never claim the bridge is known to better than ±5%
def _d(x):
try:
return _dt.date.fromisoformat(str(x)[:10])
except (TypeError, ValueError):
return None
def _f(v):
try:
x = float(v)
return None if x != x else x
except (TypeError, ValueError):
return None
def observations(quarterly_adjusted, gaap_eps_rows, ttm_adjusted=None, ttm_gaap=None, ttm_end=None):
"""Like-for-like (adjusted, GAAP, ratio) observations, oldest first.
``quarterly_adjusted``: [{fiscalDateEnding, reportedEPS}] (provider quarters).
``gaap_eps_rows``: [{start, end, value, days}] SEC diluted EPS rows (annual rows are used).
"""
q = sorted(((_d(x.get("fiscalDateEnding")), _f(x.get("reportedEPS"))) for x in quarterly_adjusted or []),
key=lambda t: (t[0] or _dt.date.min))
q = [(e, v) for e, v in q if e and v is not None]
obs = []
for r in gaap_eps_rows or []:
if not (350 <= int(r.get("days") or 0) <= 380):
continue
s, e, g = _d(r.get("start")), _d(r.get("end")), _f(r.get("value"))
if not (s and e and g) or g <= 0:
continue
inside = [v for (qe, v) in q if s < qe <= e + _dt.timedelta(days=10)]
if len(inside) != 4:
continue
adj = sum(inside)
ratio = adj / g
if RATIO_BOUNDS[0] <= ratio <= RATIO_BOUNDS[1]:
obs.append({"period": f"FY ending {e.isoformat()}", "start": s.isoformat(), "end": e.isoformat(),
"adjusted_eps": round(adj, 4), "gaap_eps": g, "ratio": ratio, "kind": "fiscal year"})
ta, tg = _f(ttm_adjusted), _f(ttm_gaap)
if ta and tg and tg > 0 and RATIO_BOUNDS[0] <= ta / tg <= RATIO_BOUNDS[1]:
te = str(ttm_end or "")[:10]
if not any(o["end"] == te for o in obs):
obs.append({"period": f"TTM ending {te}", "start": None, "end": te, "adjusted_eps": ta, "gaap_eps": tg,
"ratio": ta / tg, "kind": "trailing twelve months"})
obs.sort(key=lambda o: o["end"])
return obs
def build(quarterly_adjusted, gaap_eps_rows, ttm_adjusted=None, ttm_gaap=None, ttm_end=None,
adjusted_definition=D.PROVIDER_REPORTED_EPS, adjusted_provider="Alpha Vantage"):
"""AdjustmentBridge dict, or an unavailable record with the reason."""
obs = observations(quarterly_adjusted, gaap_eps_rows, ttm_adjusted, ttm_gaap, ttm_end)
base = {"definition_id": adjusted_definition, "adjusted_provider": adjusted_provider,
"gaap_definition": "GAAP diluted EPS (SEC XBRL)", "numerator": "adjusted EPS (provider quarters summed)",
"denominator": "GAAP diluted EPS, same period", "share_basis": "per diluted share, same period (like for like)",
"historical_ratios": [{k: (round(v, 5) if isinstance(v, float) else v) for k, v in o.items()} for o in obs]}
if not obs:
return dict(base, available=False, selected_ratio=None, selected_method="unavailable",
reason="no period with positive GAAP EPS and a matching adjusted EPS series",
uncertainty=None, methodology_change=False)
ratios = [o["ratio"] for o in obs]
latest = ratios[-1]
prior = ratios[:-1][-4:]
change = False
if len(prior) >= 2:
med_prior = statistics.median(prior)
change = abs(latest / med_prior - 1.0) > CHANGE_THRESHOLD
if change:
sel, method = latest, (f"latest observation only: it departs {abs(latest / statistics.median(prior) - 1):.0%} from the "
"prior median: a structural break (the adjustment definition, or the size of the items it excludes, "
"changed), so older ratios are not carried forward")
lo, hi = latest * (1 - max(0.10, MIN_UNCERTAINTY)), latest * (1 + max(0.10, MIN_UNCERTAINTY))
valid_from = obs[-1]["start"] or obs[-1]["end"]
elif len(ratios) >= 3:
recent = ratios[-3:]
sel, method = statistics.median(recent), "median of the last three like-for-like observations"
lo, hi = min(recent), max(recent)
valid_from = obs[-3]["start"] or obs[-3]["end"]
else:
sel = latest
method = "latest observation (fewer than three like-for-like observations)"
lo, hi = min(ratios), max(ratios)
valid_from = obs[0]["start"] or obs[0]["end"]
lo, hi = min(lo, sel * (1 - MIN_UNCERTAINTY)), max(hi, sel * (1 + MIN_UNCERTAINTY))
trend = "flat"
if len(ratios) >= 3:
d = ratios[-1] - ratios[-3]
trend = "rising" if d > 0.03 else "falling" if d < -0.03 else "flat"
return dict(base, available=True, selected_ratio=sel, selected_method=method,
median=statistics.median(ratios), range=[min(ratios), max(ratios)], latest=latest, trend=trend,
uncertainty={"low": lo, "high": hi}, methodology_change=change,
valid_from=valid_from, valid_to=obs[-1]["end"], n_observations=len(obs),
definition_version=(f"{adjusted_definition}@{valid_from}" if change else adjusted_definition))
def latest_diluted_shares(series):
"""(value, source) — the most recent quarterly weighted-average diluted share count from the filings,
else the trailing annual one, else None."""
rows = sorted(series or [], key=lambda r: (r.get("end") or "", -(r.get("days") or 0)))
q = [r for r in rows if 80 <= int(r.get("days") or 0) <= 100]
if q:
return q[-1]["value"], f"SEC weighted-average diluted shares, quarter ending {q[-1]['end']}"
a = [r for r in rows if 350 <= int(r.get("days") or 0) <= 380]
if a:
return a[-1]["value"], f"SEC weighted-average diluted shares, year ending {a[-1]['end']}"
return None, None