readthrough / analytics /impact.py
Viney's picture
feat: read per-product and per-region results from the filing's XBRL instance
232b6f9
Raw History Blame Contribute Delete
7.45 kB
"""How much a driver moved a number — computed, never estimated.
The question "what did the supply constraint cost in margin?" has three
possible answers and only two of them are allowed here.
**Computed.** Arithmetic on figures the company disclosed. A product line's
contribution to group growth is exactly ``(revenue_now - revenue_prior) /
group_revenue_prior``; the contributions sum to the group growth rate, which
is the checksum. A margin change splits exactly into a mix effect and a rate
effect. Both are reproducible from the filing and are computed here.
**Disclosed.** The company's own quantification — a guided margin range
against a reported one. The model may transcribe those figures under the
usual citation rules, and ``bridge()`` does the subtraction. It never
supplies a figure of its own.
**Estimated.** "Supply constraints probably cost 150bp." Not produced here,
not accepted from a model, and not renderable. A number with no source is
indistinguishable from a number that is wrong, and this product's whole
claim is that it does not print those.
"""
from __future__ import annotations
from typing import Optional
from ingestion.segments import PRODUCT_AXIS, SEGMENT_AXIS
def growth_contributions(ticker: str, axis: str = PRODUCT_AXIS) -> dict:
"""Each member's contribution, in percentage points, to group growth.
A line growing 29% matters less than one growing 12% if the first is a
tenth of the second's size. Contribution is the figure that settles it,
and the contributions add up to the group growth rate -- returned as
``total_pp`` so the arithmetic can be checked on screen.
"""
from analytics.segments import breakdown
view = breakdown(ticker, axis)
lines = [
line for line in (view.get("lines") or [])
if line.get("revenue") and line.get("prior_revenue")
]
if not lines:
return {}
prior_total = sum(line["prior_revenue"] for line in lines)
if not prior_total:
return {}
contributions = [
{
"label": line["label"],
"delta": line["revenue"] - line["prior_revenue"],
"contribution_pp": (line["revenue"] - line["prior_revenue"]) / prior_total * 100,
"yoy_pct": line.get("yoy_pct"),
"revenue": line["revenue"],
}
for line in lines
]
contributions.sort(key=lambda c: c["contribution_pp"], reverse=True)
return {
"axis": axis,
"period_end": view.get("period_end"),
"prior_period_end": view.get("prior_period_end"),
"lines": contributions,
"total_pp": sum(c["contribution_pp"] for c in contributions),
"source_url": view.get("source_url") or "",
}
def margin_decomposition(ticker: str) -> dict:
"""Split the segment margin change into mix and rate, exactly.
Two ways a group margin can move: the business earns more per dollar in
the places it already sells (rate), or the sales shift towards places
that already earned more (mix). Standard two-factor split --
mix = sum over members of (weight_now - weight_prior) * margin_prior
rate = sum over members of weight_now * (margin_now - margin_prior)
-- and the two add to the total change, which is returned so the identity
can be shown rather than asserted. Only the reportable-segment axis
carries operating income, so this is a regional read.
"""
from analytics.segments import breakdown
from storage import segments_db
view = breakdown(ticker, SEGMENT_AXIS)
period_end = view.get("period_end")
prior_end = view.get("prior_period_end")
if not period_end or not prior_end:
return {}
prior_rows = {
row["member"]: row
for row in segments_db.read_segments(ticker, SEGMENT_AXIS, prior_end)
}
pairs = []
for line in view.get("lines") or []:
was = prior_rows.get(line.get("member"))
if not was or not was.get("revenue") or not line.get("revenue"):
continue
if was.get("operating_income") is None or line.get("operating_income") is None:
continue
pairs.append((line, was))
if len(pairs) < 2:
return {}
total_now = sum(line["revenue"] for line, _was in pairs)
total_prior = sum(was["revenue"] for _line, was in pairs)
oi_now = sum(line["operating_income"] for line, _was in pairs)
oi_prior = sum(was["operating_income"] for _line, was in pairs)
if not total_now or not total_prior:
return {}
margin_now = oi_now / total_now * 100
margin_prior = oi_prior / total_prior * 100
members = []
mix_total = rate_total = 0.0
for line, was in pairs:
weight_now = line["revenue"] / total_now
weight_prior = was["revenue"] / total_prior
rate_prior = was["operating_income"] / was["revenue"] * 100
rate_now = line["operating_income"] / line["revenue"] * 100
mix = (weight_now - weight_prior) * rate_prior
rate = weight_now * (rate_now - rate_prior)
mix_total += mix
rate_total += rate
members.append({
"label": line["label"],
"margin_now_pct": rate_now,
"margin_prior_pct": rate_prior,
"mix_pp": mix,
"rate_pp": rate,
"total_pp": mix + rate,
})
members.sort(key=lambda m: m["total_pp"], reverse=True)
return {
"period_end": period_end,
"prior_period_end": prior_end,
"margin_now_pct": margin_now,
"margin_prior_pct": margin_prior,
"change_pp": margin_now - margin_prior,
"mix_pp": mix_total,
"rate_pp": rate_total,
"members": members,
"source_url": view.get("source_url") or "",
}
def bridge(impact: dict) -> Optional[dict]:
"""Turn one disclosed driver->metric claim into a computed delta.
*impact* carries only figures the company stated, transcribed by the
model under the citation rules: where the metric is now, and where the
company says it is going. The subtraction happens here so that no model
ever reports a delta it worked out itself.
Returns None when the claim is not arithmetically usable, which keeps a
half-filled impact off the page rather than rendering "from 50.1% to
None".
"""
if not isinstance(impact, dict):
return None
try:
current = float(impact["from_value"])
low = float(impact["to_low"])
high = float(impact.get("to_high", impact["to_low"]))
except (KeyError, TypeError, ValueError):
return None
if low > high:
low, high = high, low
# Basis points read better than "percentage points of a percentage" for
# a margin; anything else keeps the metric's own unit.
unit = str(impact.get("unit") or "%")
delta_low, delta_high = low - current, high - current
return {
"driver": str(impact.get("driver") or "").strip(),
"metric": str(impact.get("metric") or "").strip(),
"basis": str(impact.get("basis") or "disclosed"),
"unit": unit,
"from_value": current,
"to_low": low,
"to_high": high,
"delta_low_pp": delta_low,
"delta_high_pp": delta_high,
"delta_low_bp": delta_low * 100,
"delta_high_bp": delta_high * 100,
"is_range": abs(high - low) > 1e-9,
"fact_ids": [str(i) for i in (impact.get("fact_ids") or [])],
}