File size: 14,655 Bytes
931437f b4f6be8 931437f b4f6be8 931437f b4f6be8 931437f b4f6be8 931437f b4f6be8 931437f b4f6be8 931437f b4f6be8 931437f b4f6be8 931437f b4f6be8 931437f 30b61f5 931437f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | """CanonicalSegmentState: reportable segments reconciled to the company before anything uses them.
A filing's XBRL instance can carry one segment under more than one member: the operating-segment
member in the segment note (with operating income) and another member of the same segment in the
revenue-disaggregation note (revenue by product line, no operating income). Naming members by a
display string and treating each as a business counted the same revenue twice. Here:
1. Every segment is identified by its XBRL member QName (``segment_id``), with its axis and period.
2. Members that are the same segment are merged as aliases: the same normalized name, or equal
revenue for the same period where one member's name contains the other's. The member that carries
operating income is kept as the operating segment; the others are recorded as aliases.
3. Business-line revenue must sum to consolidated revenue for the same period (from the same XBRL
instance), within ``TOLERANCE``. Shares are taken of consolidated revenue, not of the row total.
4. Only then are industries assigned, as explicit allocations whose fractions sum to 1 per segment:
by segment name; else by filed product-line revenue when the product lines reconcile to the
segment; else the company's own industry.
Any failed invariant leaves the state NOT_RECONCILED, and the business-mix beta, the segment-build
requirement and the segment tables are disabled (the company's single-industry figures apply).
"""
from __future__ import annotations
import re
TOLERANCE = 0.02 # Σ segment revenue vs consolidated revenue, same period
ALIAS_REVENUE_TOLERANCE = 0.001 # two members with the same revenue to 0.1% describe the same segment
RECONCILED, NOT_RECONCILED, UNAVAILABLE, SINGLE = "RECONCILED", "NOT_RECONCILED", "UNAVAILABLE", "SINGLE_SEGMENT"
_IGNORE = re.compile(r"\b(corporate|eliminations?|unallocated|reconcil)", re.I)
def _key(name):
s = re.sub(r"(segments?|members?)$", "", re.sub(r"[^a-z0-9]", "", str(name or "").lower()))
return s
def _local(member):
return str(member or "").split(":")[-1]
def _same_revenue(a, b):
return a and b and abs(a / b - 1.0) <= ALIAS_REVENUE_TOLERANCE
def canonicalize(mix, companyfacts=None, *, tolerance=TOLERANCE):
"""CanonicalSegmentState (a dict) from ``canonical.parse_segment_mix`` output."""
mix = mix or {}
rows = [dict(g) for g in mix.get("segments") or []]
base = {"period_start": mix.get("period_start"), "period_end": mix.get("period_end"), "source": mix.get("source"),
"form": mix.get("form"), "filed": mix.get("filed"), "url": mix.get("url"),
"axis": mix.get("axis") or "us-gaap:StatementBusinessSegmentsAxis",
"consolidated_revenue": mix.get("consolidated_revenue"),
"consolidated_source": mix.get("consolidated_tag"), "segments": [], "geographic": [], "aliases": [],
"excluded": [], "invariants": []}
if not rows:
return dict(base, status=UNAVAILABLE, reason=(mix.get("error") or "no segment facts in the latest periodic report"),
business_mix_allowed=False)
for g in rows:
g["segment_id"] = g.get("member")
# every member on the segment axis is an operating segment, including one named by a region (it is the
# company's own business there); corporate / elimination rows are kept out of the list but not out of
# the revenue reconciliation
geo = [g.get("segment_id") for g in rows if g.get("kind") == "geographic"]
excl = [g for g in rows if _IGNORE.search(str(g.get("name") or ""))]
bl = [g for g in rows if g not in excl]
# ── aliases: one segment under several members ──
kept, aliases, conflicts = [], [], []
for g in sorted(bl, key=lambda x: (x.get("operating_income") is None, -(x.get("revenue") or 0))):
k = _key(g.get("name"))
twin = None
for h in kept:
hk = _key(h.get("name"))
if k == hk:
twin = h
break
if _same_revenue(g.get("revenue"), h.get("revenue")) and k and hk and (k in hk or hk in k):
twin = h
break
if twin is None:
kept.append(dict(g, aliases=[], product_lines=list(g.get("product_lines") or [])))
continue
if not _same_revenue(g.get("revenue"), twin.get("revenue")):
conflicts.append(f"{g['segment_id']} and {twin['segment_id']} share the name '{g.get('name')}' but report "
f"different revenue ({g.get('revenue'):,.0f} vs {twin.get('revenue'):,.0f})")
kept.append(dict(g, aliases=[], product_lines=list(g.get("product_lines") or [])))
continue
twin["aliases"].append(g["segment_id"])
have = {p.get("name") for p in twin["product_lines"]}
twin["product_lines"] += [p for p in g.get("product_lines") or [] if p.get("name") not in have]
for f in ("operating_income", "margin", "capex", "capex_to_revenue", "revenue_prior", "growth"):
if twin.get(f) is None and g.get(f) is not None:
twin[f] = g[f]
aliases.append({"kept": twin["segment_id"], "merged": g["segment_id"],
"reason": "same segment reported under a second member (revenue equal for the same period)"})
inv = []
ids = [g["segment_id"] for g in kept]
inv.append({"name": "unique segment ids", "ok": len(ids) == len(set(ids)) and all(ids), "detail": ", ".join(map(str, ids))})
names = [str(g.get("name") or "") for g in kept]
dup = sorted({n for n in names if names.count(n) > 1})
inv.append({"name": "unique display names", "ok": not dup and not conflicts,
"detail": "; ".join(conflicts) or (f"duplicate names: {', '.join(dup)}" if dup else "unique")})
tot = sum(g.get("revenue") or 0 for g in kept if (g.get("revenue") or 0) > 0)
other = sum(g.get("revenue") or 0 for g in excl)
cons = mix.get("consolidated_revenue")
cov = ((tot + other) / cons) if (cons and cons > 0) else None
inv.append({"name": "Σ segment revenue = consolidated revenue (same period)",
"ok": cov is not None and abs(cov - 1.0) <= tolerance,
"detail": (f"Σ segments {tot:,.0f}" + (f" + corporate/eliminations {other:,.0f}" if other else "")
+ f" vs consolidated {cons:,.0f} ({cov:.1%})" if cov is not None
else "consolidated revenue for the segment period not found in the filing")})
# completeness against the company-facts segment list when it covers the same fiscal period
cf = companyfacts or {}
if cf.get("segments") and str(cf.get("fy_end") or "")[:10] == str(mix.get("period_end") or "")[:10]:
known = set(ids) | {a for g in kept for a in g["aliases"]}
known_local = {_key(_local(x)) for x in known}
miss = [g for g in cf["segments"] if g.get("kind") != "geographic" and not _IGNORE.search(str(g.get("name") or ""))
and g.get("member") not in known and _key(_local(g.get("member"))) not in known_local
and cons and (g.get("revenue") or 0) > 0.05 * cons]
inv.append({"name": "segments complete vs company facts", "ok": not miss,
"detail": ("missing: " + ", ".join(str(g.get("member")) for g in miss)) if miss else "complete"})
for g in kept:
g["revenue_share"] = ((g.get("revenue") or 0) / cons) if (cons and cons > 0) else None
g["role"] = "operating segment" if g.get("operating_income") is not None else "segment (revenue only)"
ok = all(i["ok"] for i in inv)
status = (RECONCILED if ok else NOT_RECONCILED) if len(kept) >= 2 else SINGLE
kept.sort(key=lambda g: -(g.get("revenue") or 0))
return dict(base, segments=kept, geographic=geo, excluded=[g.get("segment_id") for g in excl], excluded_revenue=other,
aliases=aliases,
invariants=inv, sum_revenue=tot, coverage=cov, status=status,
business_mix_allowed=(status == RECONCILED),
reason=("reconciled" if ok else "; ".join(i["name"] + ": " + i["detail"] for i in inv if not i["ok"])))
def allocate(canon, company_industry, industry_of, mapper=None):
"""Add ``allocations`` [{industry, fraction, basis, mapping}] to each canonical segment (fractions sum to 1).
``mapper(segment_id, text)`` → SegmentIndustryMapping (damodaran_data.map_segment): the keyword map plus the
parent-sector compatibility gate and its fallback. Each segment keeps its mapping as ``industry_mapping`` and each
allocation carries the mapping its industry came through, so nothing downstream reads an industry without its
compatibility status. ``industry_of(text, (), fallback)`` → (industry, how) is the legacy keyword map, used only
when no mapper is given."""
if not canon or canon.get("status") not in (RECONCILED, SINGLE):
return canon
if mapper is None:
def mapper(segment_id, text): # legacy callers: the keyword map without the gate
ind, how = industry_of(text, (), None)
ok = bool(ind) or how.startswith("regional")
return {"segment_id": segment_id, "input_text": str(text or ""), "selected_industry": ind or company_industry,
"mapping_method": how, "matched_pattern": "legacy" if ind else None, "matched_text": None,
"parent_industry": company_industry, "compatibility_status": "PASS", "rejected": [],
"fallback_used": None if ind else "COMPANY_INDUSTRY", "eligible_for_value_weight": ok or bool(company_industry),
"eligible_for_beta": ok or bool(company_industry)}
for g in canon.get("segments") or []:
mp = mapper(g.get("segment_id"), g.get("name"))
g["industry_mapping"] = mp
how = str(mp.get("mapping_method") or "")
if how.startswith("regional"):
g["allocations"] = [{"industry": company_industry, "fraction": 1.0, "mapping": mp,
"basis": "company industry (a segment named by a region is the company's own business there)"}]
continue
if mp.get("matched_pattern"):
g["allocations"] = [{"industry": mp["selected_industry"], "fraction": 1.0, "basis": f"segment name ({how})",
"mapping": mp}]
continue
rejected = "; ".join(f"{r.get('industry')} rejected: {r.get('reason')}" for r in mp.get("rejected") or [])
pl = [p for p in g.get("product_lines") or [] if (p.get("revenue") or 0) > 0]
psum = sum(p["revenue"] for p in pl)
if pl and g.get("revenue") and abs(psum / g["revenue"] - 1.0) <= TOLERANCE:
acc, via = {}, {}
for p in pl:
pm = mapper(g.get("segment_id"), p.get("name"))
pi = pm["selected_industry"] if pm.get("matched_pattern") else company_industry
acc[pi] = acc.get(pi, 0.0) + p["revenue"] / psum
via.setdefault(pi, pm if pm.get("matched_pattern") else mp)
g["allocations"] = [{"industry": k, "fraction": v, "basis": "filed product-line revenue", "mapping": via[k]}
for k, v in sorted(acc.items(), key=lambda kv: -kv[1])]
else:
g["allocations"] = [{"industry": mp.get("selected_industry"), "fraction": 1.0, "mapping": mp,
"basis": ("company industry (" + (f"the segment name's keyword match was not compatible: {rejected}"
if rejected else "segment name has no industry match")
+ ("; product-line revenue does not reconcile to the segment)" if pl else ")"))}]
bad = [g["segment_id"] for g in canon.get("segments") or []
if abs(sum(a["fraction"] for a in g.get("allocations") or []) - 1.0) > 1e-9]
canon["invariants"] = list(canon.get("invariants") or []) + [
{"name": "industry allocation fractions sum to 1", "ok": not bad, "detail": ", ".join(bad) or "all segments"}]
if bad:
canon["status"], canon["business_mix_allowed"] = NOT_RECONCILED, False
canon["reason"] = "industry allocation fractions do not sum to 1: " + ", ".join(bad)
return canon
def for_perimeter(canon, excluded_segment_ids, action_id=None):
"""The segment set of a valuation perimeter: the canonical segments less those that belong to a separated
business (by segment id, resolved once from the corporate action). Shares are re-based to the remaining set."""
c = dict(canon or {})
ex = set(excluded_segment_ids or ())
keep = [dict(g) for g in c.get("segments") or [] if g.get("segment_id") not in ex]
tot = sum(g.get("revenue") or 0 for g in keep if (g.get("revenue") or 0) > 0)
for g in keep:
g["revenue_share"] = ((g.get("revenue") or 0) / tot) if tot else None
c.update(segments=keep, perimeter="continuing company", perimeter_action_id=action_id,
excluded_for_perimeter=sorted(ex), reason=(c.get("reason") or "") + (f"; {len(ex)} segment(s) of the separated "
f"business excluded ({action_id})" if ex else ""))
return c
def check(canon, tol=TOLERANCE):
"""[(name, ok, detail)] — the invariants recomputed from the state alone (used by validation)."""
out = []
if not canon or canon.get("status") in (UNAVAILABLE, None):
return out
segs = canon.get("segments") or []
ids = [g.get("segment_id") for g in segs]
out.append(("unique segment ids", len(ids) == len(set(ids)) and all(ids), ", ".join(map(str, ids))))
if canon.get("status") == RECONCILED:
cons = canon.get("consolidated_revenue")
tot = sum(g.get("revenue") or 0 for g in segs) + (canon.get("excluded_revenue") or 0)
out.append(("Σ segment revenue = consolidated", bool(cons) and abs(tot / cons - 1) <= tol,
f"{tot:,.0f} vs {cons or 0:,.0f}"))
sh = sum(g.get("revenue_share") or 0 for g in segs) + ((canon.get("excluded_revenue") or 0) / cons if cons else 0)
out.append(("segment shares sum to 1", abs(sh - 1) <= tol, f"{sh:.4f}"))
for g in segs:
fr = sum(a.get("fraction") or 0 for a in g.get("allocations") or [])
if g.get("allocations") is not None and abs(fr - 1) > 1e-6:
out.append((f"allocations of {g.get('segment_id')} sum to 1", False, f"{fr:.6f}"))
return out
|