File size: 42,442 Bytes
4e0c85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2baf418
931437f
 
658e4d8
4e0c85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2baf418
658e4d8
 
4e0c85a
931437f
658e4d8
2baf418
658e4d8
2baf418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e0c85a
 
 
 
 
 
 
 
 
 
 
 
2baf418
 
931437f
 
 
 
 
 
658e4d8
 
 
4e0c85a
 
 
2baf418
4e0c85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931437f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30b61f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4f6be8
 
 
30b61f5
b4f6be8
30b61f5
 
 
 
2baf418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e0c85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1406987
 
b4f6be8
 
 
 
 
 
 
 
 
1406987
b4f6be8
 
 
 
 
 
 
 
a4e00af
b4f6be8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1406987
 
a4e00af
 
 
 
 
1406987
 
b4f6be8
 
 
 
 
 
 
 
 
 
 
1406987
a4e00af
 
 
 
1406987
b4f6be8
 
 
1406987
 
 
b4f6be8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1406987
2baf418
 
 
b4f6be8
 
 
 
 
1406987
 
931437f
1406987
931437f
 
b4f6be8
 
 
 
 
931437f
 
 
b4f6be8
 
 
 
 
 
 
 
 
 
 
931437f
 
b4f6be8
 
 
 
 
 
 
a4e00af
 
1406987
2baf418
 
 
 
 
1406987
2baf418
1406987
 
2baf418
1406987
 
2baf418
 
 
 
 
b4f6be8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2baf418
 
 
 
 
 
792a33c
 
 
 
 
2baf418
 
792a33c
 
 
 
 
 
 
931437f
 
 
 
 
 
 
 
 
 
 
 
 
2baf418
 
 
792a33c
 
 
 
 
 
 
 
 
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
"""Aswath Damodaran's published datasets, as data inputs to the valuation.

Source: https://pages.stern.nyu.edu/~adamodar/ (datasets are his; he updates the industry files
each January, the country risk premiums in January and July, and the implied equity risk premium
monthly). Nothing here is fitted to his valuations: these are the same inputs he uses.

* ``build(src_dir)`` parses the downloaded files into ``damodaran_data.json.gz`` (run locally or by
  the refresh chain; needs xlrd/openpyxl). The Space only loads the JSON.
* Loader functions: ``implied_erp``, ``country_premium``, ``company_industry``, ``industry_stats``,
  ``bottom_up_beta``, ``company_erp``.
"""
from __future__ import annotations

import datetime as _dt
import gzip
import json
import os
import re

DATA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "damodaran_data.json.gz")
BASE_URL = "https://pages.stern.nyu.edu/~adamodar/pc/"
SOURCES = {
    "betas_us": "datasets/betas.xls", "betas_global": "datasets/betaGlobal.xls",
    "capex_us": "datasets/capex.xls", "capex_global": "datasets/capexGlobal.xls",
    "margin_us": "datasets/margin.xls", "margin_global": "datasets/marginGlobal.xls",
    "wacc_us": "datasets/wacc.xls", "wacc_global": "datasets/waccGlobal.xls",
    "ps_us": "datasets/psdata.xls", "ps_global": "datasets/psGlobal.xls",
    "pe_us": "datasets/pedata.xls", "pe_global": "datasets/peGlobal.xls", "companies": "datasets/indname.xls",
    "roe_us": "datasets/roe.xls", "roe_global": "datasets/roeGlobal.xls",
    "country": "datasets/ctrypremJuly26.xlsx", "erp_monthly": "implprem/ERPbymonth.xlsx",
}
# US listings in his company file (exchange prefixes); everything else is a non-US listing
US_EXCHANGES = ("NYSE", "NasdaqGS", "NasdaqGM", "NasdaqCM", "NYSEAM", "ARCA", "BATS", "OTCPK", "OTCQX", "OTCQB", "NYSEArca")
_SUFFIX = re.compile(r"\b(inc|corp|corporation|co|company|ltd|limited|plc|sa|ag|nv|se|holdings?|group|the|llc|lp|"
                     r"incorporated|n\.v|s\.a|a/s|asa|spa|s\.p\.a|kgaa|oyj|ab|bhd|tbk|pcl)\b\.?", re.I)


def _norm_name(name):
    n = re.sub(r"\([^)]*\)", " ", str(name or ""))
    n = _SUFFIX.sub(" ", n.replace("&", " and "))
    return re.sub(r"[^a-z0-9]+", " ", n.lower()).strip()


# ═══════════════════════════════════════════════════════════════════════════════════════
# builder (local / refresh chain)
# ═══════════════════════════════════════════════════════════════════════════════════════
def _num(v):
    try:
        if v is None:
            return None
        if isinstance(v, str):
            pct = "%" in v
            v = v.strip().replace("%", "").replace(",", ".")
            x = float(v) / 100.0 if pct else float(v)
        else:
            x = float(v)
        return None if x != x else x
    except (TypeError, ValueError):
        return None


def _industry_table(path):
    import pandas as pd
    raw = pd.read_excel(path, sheet_name="Industry Averages", header=None)
    h = raw.index[raw.iloc[:, 0].astype(str).str.strip().eq("Industry Name")][0]
    df = raw.iloc[h + 1:].copy()
    df.columns = [str(c).strip() for c in raw.iloc[h]]
    date = raw.iloc[0, 1]
    return df.dropna(subset=["Industry Name"]), (str(date)[:10] if date is not None else None)


def build(src_dir, out=DATA_FILE):
    import pandas as pd
    p = lambda k: os.path.join(src_dir, os.path.basename(SOURCES[k]))
    data = {"meta": {"source": "Aswath Damodaran, https://pages.stern.nyu.edu/~adamodar/",
                     "files": {k: BASE_URL + v for k, v in SOURCES.items()},
                     "built_at": _dt.date.today().isoformat()}}
    # implied ERP by month (his headline figure: trailing-12-month cash yield, column "ERP (T12m)")
    e = pd.read_excel(p("erp_monthly"), sheet_name="Historical ERP")
    rows = []
    for _, r in e.iterrows():
        d, v = r.get("Start of month"), _num(r.get("ERP (T12m)"))
        if d is not None and v is not None and str(d) != "NaT":
            rows.append({"month": str(d)[:10], "erp": round(v, 5), "tbond": _num(r.get("T.Bond Rate"))})
    data["erp"] = {"measure": "implied ERP, trailing 12-month cash yield (ERP (T12m))", "monthly": rows[-36:]}
    # country risk premiums (rating-based default spread × relative equity volatility)
    c = pd.read_excel(p("country"), sheet_name="ERPs by country", header=None)
    h = c.index[c.iloc[:, 0].astype(str).str.strip().eq("Country")][0]
    mature = _num(c.iloc[2, 4])
    countries = {}
    for _, r in c.iloc[h + 1:].iterrows():
        name = str(r.iloc[0]).strip()
        if not name or name == "nan" or _num(r.iloc[5]) is None:
            continue
        countries[name] = {"region": str(r.iloc[1]), "rating": str(r.iloc[2]), "default_spread": _num(r.iloc[3]),
                           "total_erp": _num(r.iloc[4]), "crp": _num(r.iloc[5])}
    data["country"] = {"as_of": str(c.iloc[1, 1])[:10], "mature_erp": mature, "countries": countries}
    # industries: US and global tables
    inds = {}
    market = {}
    for region, keys in (("US", ("betas_us", "capex_us", "margin_us", "ps_us", "wacc_us", "pe_us", "roe_us")),
                         ("Global", ("betas_global", "capex_global", "margin_global", "ps_global", "wacc_global", "pe_global", "roe_global"))):
        b, bd = _industry_table(p(keys[0])); cx, _ = _industry_table(p(keys[1])); m, _ = _industry_table(p(keys[2]))
        ps, _ = _industry_table(p(keys[3])); wc, wd = _industry_table(p(keys[4])); pe, _ = _industry_table(p(keys[5]))
        roe, _ = _industry_table(p(keys[6]))
        cx = cx.set_index("Industry Name"); m = m.set_index("Industry Name"); ps = ps.set_index("Industry Name")
        pe = pe.set_index("Industry Name"); roe = roe.set_index("Industry Name")
        # the mature company: his "Total Market (without financials)" row of the cost-of-capital file, with
        # the Treasury rate and marginal tax rate he estimated it at (so it can be rebuilt at today's rates)
        wraw = pd.read_excel(p(keys[4]), sheet_name="Industry Averages", header=None)
        def lab(s):   # the first number to the right of a label on the input panel
            for _, r in wraw.iterrows():
                if str(r.iloc[0]).strip().startswith(s):
                    return next((_num(v) for v in list(r.iloc[1:8]) if _num(v) is not None), None)
            return None
        tm = wc.set_index("Industry Name").loc["Total Market (without financials)"]
        rf_then = lab("Long Term Treasury bond rate")
        market[region] = {"as_of": wd, "row": "Total Market (without financials)", "beta": _num(tm.get("Beta")),
                          "debt_weight": _num(tm.get("D/(D+E)")), "pre_tax_cost_of_debt": _num(tm.get("Cost of Debt")),
                          "default_spread": (_num(tm.get("Cost of Debt")) - rf_then) if rf_then is not None else None,
                          "marginal_tax": lab("If yes, enter the marginal tax rate"), "rf": rf_then,
                          "erp": lab("Risk Premium to Use for Equity"), "cost_of_capital": _num(tm.get("Cost of Capital")),
                          "n_firms": _num(tm.get("Number of Firms"))}
        tab = {}
        for _, r in b.iterrows():
            n = str(r["Industry Name"]).strip()
            row = {"n_firms": _num(r.get("Number of firms")), "levered_beta": _num(r.get("Beta")), "d_e": _num(r.get("D/E Ratio")),
                   "tax_rate": _num(r.get("Effective Tax rate")), "unlevered_beta": _num(r.get("Unlevered beta")),
                   "unlevered_beta_cash": _num(r.get("Unlevered beta corrected for cash"))}
            if n in cx.index:
                row["sales_to_capital"] = _num(cx.loc[n].get("Sales/ Invested Capital (LTM)"))
            if n in m.index:
                row["operating_margin"] = _num(m.loc[n].get("Pre-tax Unadjusted Operating Margin"))
                row["operating_margin_adj"] = _num(m.loc[n].get("Pre-tax Lease & R&D adj Margin"))
                row["net_margin"] = _num(m.loc[n].get("Net Margin"))
            if n in ps.index:
                row["ev_sales"] = _num(ps.loc[n].get("EV/Sales"))
                row["price_sales"] = _num(ps.loc[n].get("Price/Sales"))
            if n in pe.index:
                # aggregate market cap ÷ trailing net income of the profitable firms: robust to loss-makers, unlike
                # the average of firm P/Es (Software averages 122x with 70% of firms losing money)
                row["pe_aggregate_profitable"] = _num(pe.loc[n].get("Aggregate Mkt Cap/ Trailing Net Income (only money making firms)"))
                row["pe_forward"] = _num(pe.loc[n].get("Forward PE"))
                row["expected_growth_5y"] = _num(pe.loc[n].get("Expected growth - next 5 years"))
            if n in roe.index:
                row["roe"] = _num(roe.loc[n].get("ROE (unadjusted)"))
            tab[n] = row
        inds[region] = {"as_of": bd, "industries": tab}
    data["industries"] = inds
    data["market"] = market
    # companies → industry and country (his classification)
    comp = pd.read_excel(p("companies"), sheet_name="By company name")
    companies, names, sic_counts = {}, {}, {}
    for _, r in comp.iterrows():
        key, ind = str(r.get("Exchange:Ticker") or "").strip(), str(r.get("Industry Group") or "").strip()
        if not key or not ind or ind == "nan":
            continue
        country = str(r.get("Country") or "").strip()
        sic = str(r.get("SIC Code") or "").split(".")[0]
        companies[key] = [ind, country]
        nm = _norm_name(r.get("Company Name"))
        if nm and nm not in names:
            names[nm] = key
        if country == "United States" and sic.isdigit():
            sic_counts.setdefault(sic, {}).setdefault(ind, 0)
            sic_counts[sic][ind] += 1
    data["companies"] = companies
    data["names"] = names
    data["sic_industry"] = {s: max(v.items(), key=lambda kv: kv[1])[0] for s, v in sic_counts.items()}
    with gzip.open(out, "wt", encoding="utf-8") as fh:
        json.dump(data, fh, separators=(",", ":"))
    return {"erp_months": len(rows), "countries": len(countries), "companies": len(companies),
            "industries_us": len(inds["US"]["industries"]), "industries_global": len(inds["Global"]["industries"])}


# ═══════════════════════════════════════════════════════════════════════════════════════
# loader (engine)
# ═══════════════════════════════════════════════════════════════════════════════════════
_CACHE = {"data": None, "loaded": False}
OVERRIDE_FILE = os.path.join(os.getenv("CRUCIBLE_CACHE") or os.path.dirname(os.path.abspath(__file__)), "damodaran_erp_monthly.json")


def _erp_rows():
    rows = list((_data() or {}).get("erp", {}).get("monthly") or [])
    try:
        with open(OVERRIDE_FILE) as fh:
            extra = json.load(fh).get("monthly") or []
        by = {r["month"]: r for r in rows}
        by.update({r["month"]: r for r in extra})
        rows = [by[k] for k in sorted(by)]
    except (OSError, ValueError):
        pass
    return rows


def refresh_erp_if_stale(max_age_days=35, timeout=30):
    """His implied ERP is published monthly. When the newest month we hold is older than
    ``max_age_days``, fetch his monthly file and cache the parsed rows (the bundle stays as is).
    Returns a short status string; a failed fetch leaves the bundled months in use."""
    rows = _erp_rows()
    if rows:
        age = (_dt.date.today() - _dt.date.fromisoformat(rows[-1]["month"][:10])).days
        if age <= max_age_days:
            return f"current ({rows[-1]['month']})"
    try:
        import io
        import pandas as pd
        import requests
        r = requests.get(BASE_URL + SOURCES["erp_monthly"], timeout=timeout)
        r.raise_for_status()
        e = pd.read_excel(io.BytesIO(r.content), sheet_name="Historical ERP")
        new = []
        for _, x in e.iterrows():
            d, v = x.get("Start of month"), _num(x.get("ERP (T12m)"))
            if d is not None and v is not None and str(d) != "NaT":
                new.append({"month": str(d)[:10], "erp": round(v, 5), "tbond": _num(x.get("T.Bond Rate"))})
        with open(OVERRIDE_FILE, "w") as fh:
            json.dump({"fetched": _dt.date.today().isoformat(), "monthly": new[-36:]}, fh)
        return f"refreshed to {new[-1]['month']}" if new else "fetched, no rows"
    except Exception as ex:  # noqa: BLE001
        return f"refresh failed ({str(ex)[:80]}); using {rows[-1]['month'] if rows else 'none'}"


def _data():
    if not _CACHE["loaded"]:
        _CACHE["loaded"] = True
        try:
            with gzip.open(DATA_FILE, "rt", encoding="utf-8") as fh:
                _CACHE["data"] = json.load(fh)
        except (OSError, ValueError):
            _CACHE["data"] = None
    return _CACHE["data"]


def available():
    return _data() is not None


def implied_erp(on=None):
    """{value, month, measure} — his implied ERP for the latest month at or before ``on``."""
    d = _data()
    if not d:
        return None
    rows = _erp_rows()
    if on:
        rows = [r for r in rows if r["month"] <= str(on)[:10]] or rows
    r = rows[-1]
    return {"value": r["erp"], "month": r["month"], "measure": d["erp"]["measure"], "source": d["meta"]["files"]["erp_monthly"]}


_COUNTRY_ALIASES = {"USA": "United States", "US": "United States", "UK": "United Kingdom", "Korea": "Korea",
                    "South Korea": "Korea", "Republic of Korea": "Korea", "Hong Kong SAR": "Hong Kong", "Netherlands (the)": "Netherlands"}


def country_premium(country):
    """{crp, rating, total_erp, as_of} for a country of operations, or None when not in his table."""
    d = _data()
    if not d or not country:
        return None
    name = _COUNTRY_ALIASES.get(str(country).strip(), str(country).strip())
    row = d["country"]["countries"].get(name)
    if row is None:
        low = name.lower()
        row = next((v for k, v in d["country"]["countries"].items() if k.lower() == low or k.lower().startswith(low)), None)
    return dict(row, country=name, as_of=d["country"]["as_of"], mature_erp=d["country"]["mature_erp"]) if row else None


def company_industry(ticker, name=None, sic=None):
    """(industry, country, how) from his company list: the US listing ticker first, then the company
    name (foreign issuers are listed under their home ticker), then the SIC code's modal industry."""
    d = _data()
    if not d:
        return None, None, None
    t = str(ticker or "").upper()
    for ex in US_EXCHANGES:
        hit = d["companies"].get(f"{ex}:{t}")
        if hit:
            return hit[0], hit[1], f"ticker {ex}:{t}"
    nm = _norm_name(name)
    if nm and nm in d["names"]:
        k = d["names"][nm]
        ind, ctry = d["companies"][k]
        return ind, ctry, f"company name → {k}"
    s = str(sic or "").split(".")[0]
    if s and s in d["sic_industry"]:
        return d["sic_industry"][s], None, f"SIC {s} (modal industry)"
    return None, None, None


def industry_stats(industry, region="US"):
    d = _data()
    if not d or not industry:
        return None
    tab = d["industries"].get(region) or d["industries"]["US"]
    row = tab["industries"].get(industry)
    return dict(row, industry=industry, region=region, as_of=tab["as_of"]) if row else None


def bottom_up_beta(stats, debt, market_cap, tax=0.25):
    """His bottom-up beta: the industry's unlevered beta corrected for cash, re-levered at the
    company's market debt-to-equity: βL = βu × (1 + (1 − t) × D/E)."""
    if not stats:
        return None
    bu = stats.get("unlevered_beta_cash") or stats.get("unlevered_beta")
    if bu is None:
        return None
    de = (float(debt) / float(market_cap)) if (debt and market_cap and market_cap > 0) else 0.0
    return float(bu) * (1.0 + (1.0 - tax) * min(max(de, 0.0), 5.0))


def company_erp(country, on=None):
    """The ERP for a company operating in ``country``: his implied (mature-market) ERP plus the
    country risk premium of the country of operations (zero for the US and Aaa countries)."""
    base = implied_erp(on)
    if base is None:
        return None
    cp = country_premium(country) if country else None
    us = str(country or "United States").strip() in ("United States", "USA", "US", "")
    crp = 0.0 if (us or cp is None) else float(cp.get("crp") or 0.0)
    return {"erp": base["value"] + crp, "implied_erp": base["value"], "crp": crp, "country": (cp or {}).get("country") or country,
            "rating": (cp or {}).get("rating"), "erp_month": base["month"], "crp_as_of": (cp or {}).get("as_of"),
            "basis": ("implied ERP + country risk premium of the country of operations" if crp else "implied ERP (mature market)")}


def industry_multiples(industry, region="US"):
    """Sourced industry pricing anchors: {pe: {value, basis, source, date, universe}, ps: {...}} or {}."""
    st = industry_stats(industry, region) or industry_stats(industry, "US") or {}
    d = _data() or {}
    out = {}
    if st.get("pe_aggregate_profitable"):
        out["pe"] = {"value": st["pe_aggregate_profitable"], "basis": "TRAILING_GAAP (aggregate market cap ÷ trailing net income, "
                     "profitable firms)", "source": "Damodaran pedata/peGlobal", "date": st.get("as_of"),
                     "universe": f"{st.get('region')} {industry} ({int(st.get('n_firms') or 0)} firms)"}
    if st.get("price_sales"):
        out["ps"] = {"value": st["price_sales"], "basis": "TRAILING (aggregate market cap ÷ trailing revenue)",
                     "source": "Damodaran psdata/psGlobal", "date": st.get("as_of"),
                     "universe": f"{st.get('region')} {industry} ({int(st.get('n_firms') or 0)} firms)"}
    return out


# industries too broad or economically mixed for their beta to stand as company-specific terminal evidence
BROAD_INDUSTRIES = ("Diversified", "Business & Consumer Services", "Conglomerates", "Retail (General)",
                    "Financial Svcs. (Non-bank & Insurance)", "Information Services", "Total Market")
_FINANCIAL_WORDS = ("bank", "financial svcs", "insurance", "brokerage", "investments & asset", "reit")
BETA_SANE = (0.5, 2.0)


def beta_quality(inputs, is_financial=False):
    """{status: HIGH|LOW, reasons[], beta, beta_source, beta_industry}: may this beta stand as evidence of the
    company's own risk in stable growth? Checks the industry mapping, the business-model fit of the industry,
    the business mix and the beta's statistical sanity. Peer betas are not in the data, so peer consistency is
    recorded as not assessed."""
    di = inputs or {}
    ind = str(di.get("industry") or "")
    b = di.get("beta")
    reasons = []
    if not ind:
        reasons.append("no industry classification")
    if di.get("industry_match") and not str(di.get("industry_match")).startswith("ticker"):
        reasons.append(f"industry matched by {di.get('industry_match')}, not by ticker")
    if ind in BROAD_INDUSTRIES:
        reasons.append(f"industry '{ind}' is broad or economically mixed")
    if not is_financial and any(w in ind.lower() for w in _FINANCIAL_WORDS):
        reasons.append(f"a financial-services industry beta ('{ind}') for a non-financial business: "
                       "financial firms' debt is operating, so their unlevered betas are not comparable")
    if b is None or not (BETA_SANE[0] <= float(b) <= BETA_SANE[1]):
        reasons.append(f"beta {b if b is None else round(float(b), 2)} outside the plausible range {BETA_SANE[0]}–{BETA_SANE[1]}")
    mix = di.get("business_mix") or []
    if len({r.get("industry") for r in mix}) > 2:
        reasons.append("business mix spans more than two industries")
    # every industry the business-mix beta used must come through a compatible mapping (not only the company's own)
    mix_bad = business_mix_problems(mix, ind or None, is_financial)
    reasons += [f"business-mix segment mapping incompatible: {x}" for x in mix_bad]
    return {"status": "LOW" if reasons else "HIGH", "reasons": reasons, "beta": b,
            "business_mix_beta_valid": (not mix_bad) if mix else None,
            "beta_source": di.get("beta_basis"), "beta_industry": ind or None,
            "peer_consistency": "not assessed (no peer betas in the data)"}


def mature_cost_of_capital(rf, erp, region="US", tax=None):
    """The cost of capital of a mature company, his stable-growth convention ("roughly that of an average
    company"): the beta, debt ratio and default spread of his Total Market (without financials) row,
    rebuilt at today's riskfree rate and the company's ERP. ``None`` when the dataset lacks the row."""
    d = _data()
    mk = ((d or {}).get("market") or {}).get(region) or ((d or {}).get("market") or {}).get("US")
    if not mk or rf is None or erp is None or mk.get("beta") is None or mk.get("debt_weight") is None:
        return None
    t = mk.get("marginal_tax") if tax is None else tax
    t = 0.25 if t is None else float(t)
    ke = float(rf) + float(mk["beta"]) * float(erp)
    kd = (float(rf) + float(mk.get("default_spread") or 0.0)) * (1.0 - t)
    w = float(mk["debt_weight"])
    return {"value": (1.0 - w) * ke + w * kd, "cost_of_equity": ke, "after_tax_cost_of_debt": kd, "beta": mk["beta"],
            "debt_weight": w, "default_spread": mk.get("default_spread"), "tax": t, "region": region, "as_of": mk.get("as_of"),
            "basis": f"mature company: {mk['row']} ({region}, {mk.get('as_of')}) at today's riskfree rate and ERP"}


def download(dest):
    """Fetch every source file into ``dest`` (the refresh chain calls this, then ``build``)."""
    import requests
    os.makedirs(dest, exist_ok=True)
    got = {}
    for k, path in SOURCES.items():
        r = requests.get(BASE_URL + path, timeout=120)
        r.raise_for_status()
        with open(os.path.join(dest, os.path.basename(path)), "wb") as fh:
            fh.write(r.content)
        got[k] = {"bytes": len(r.content), "last_modified": r.headers.get("last-modified")}
    return got


if __name__ == "__main__":
    import sys
    dest = sys.argv[1] if len(sys.argv) > 1 else "damodaran_src"
    print(json.dumps(download(dest), indent=1))
    print(json.dumps(build(dest), indent=1))


# ── segments → his industries (deterministic; first compatible match wins; unmatched → the company's industry) ──
def _kw(*tokens, stems=()):
    """One keyword pattern. Every TOKEN matches only as a whole word (bounded on both sides: "card" never matches
    "cardiovascular", "gas" never "gastroenterology", "power" never "powertrain"). A STEM is declared explicitly and
    matches a word that begins with it ("pharma" → pharmaceutical)."""
    parts = list(tokens) + [s + r"\w*" for s in stems]
    return r"\b(?:" + "|".join(parts) + r")\b"


SEGMENT_INDUSTRY = [
    (_kw("launch(?:es)?", "space", "rockets?", "aerospace", "defen[cs]e", "aircraft", "aviation", "avionics"), "Aerospace/Defense"),
    (_kw("connectivity", "broadband", "starlink", "wireless", "internet services?", "satellite internet", stems=("telecom",)),
     "Telecom. Services"),
    (_kw("cloud", "aws", "azure", "software", "saas", "subscription software", "productivity", "business processes"),
     "Software (System & Application)"),
    (_kw("ai", "artificial intelligence", "machine learning", "llm", "generative"), "Software (System & Application)"),
    (_kw(stems=("advertis",)), "Advertising"),
    (_kw("search", "social", "family of apps", "online services"), "Software (Internet)"),
    # "retail" alone is not a business (retail electricity, retail banking): only store/merchandise retail maps here
    (_kw("online stores?", "physical stores?", "retail stores?", "e-?commerce", "third[- ]party sellers?", "merchandise",
         "general merchandise"), "Retail (General)"),
    (_kw("semiconductors?", "chips?", "gpus?", "compute and networking", "data cent(?:er|re)s?"), "Semiconductor"),
    (_kw("automotive", "vehicles?", "auto"), "Auto & Truck"),
    (_kw("energy generation", "energy storage", "solar", "renewables?"), "Green & Renewable Energy"),
    (_kw("drugs?", "oncology", "vaccines?", stems=("pharma", "biotech", "therapeut")), "Drugs (Pharmaceutical)"),
    (_kw("medical", "medtech", "diagnostics?", "devices? health", "healthcare products"), "Healthcare Products"),
    (_kw("gaming", "video games?", "xbox"), "Software (Entertainment)"),
    (_kw("media", "entertainment", "studios?", "streaming", "content", "parks"), "Entertainment"),
    (_kw("cable"), "Cable TV"),
    (_kw("beverages?"), "Beverage (Soft)"),
    (_kw("foods?", "snacks?"), "Food Processing"),
    (_kw("hotels?", "casinos?", "resorts?", "cruises?"), "Hotel/Gaming"),
    (_kw("restaurants?"), "Restaurant/Dining"),
    (_kw("oil", "gas", "upstream", "downstream", "refining", "chemicals? and energy"), "Oil/Gas (Integrated)"),
    (_kw("chemicals?"), "Chemical (Diversified)"),
    (_kw("mining", "metals", "copper", "gold"), "Metals & Mining"),
    (_kw("machinery", "industrial automation", "automation", "motion"), "Machinery"),
    (_kw("buildings?", "hvac", "construction"), "Building Materials"),
    (_kw("power", "electric(?:ity)?", "utility", "utilities", "retail energy", "wholesale energy", "energy charge"), "Power"),
    (_kw("iphone", "mac", "ipad", "wearables", "computers?", "pcs?", "hardware", "personal computing"), "Computers/Peripherals"),
    (_kw("insurance"), "Insurance (General)"),
    (_kw("payments?", "cards?"), "Financial Svcs. (Non-bank & Insurance)"),
]
_IGNORE_SEGMENTS = re.compile(r"\b(corporate|eliminations?|unallocated|reconcil)", re.I)
# a segment named only by a place is a region of the company's own business, not a different business
_REGION_NAME = re.compile(r"^(?:(?:north|south|east|west|central|northeast|northwest|southeast|southwest|midwest|mid-?atlantic|gulf|"
                          r"pacific|atlantic|americas?|latin|europe(?:an)?|emea|asia|apac|greater|china|japan|india|canada|mexico|"
                          r"texas|california|florida|new york|international|domestic|united|states|kingdom|u\.?s\.?|uk|rest|of|the|"
                          r"world|and|other|region|regions|markets?|operations|segment|&|\s|-)+)$", re.I)


def keyword_matches(text):
    """Every (industry, pattern, matched words) the text matches, in table order."""
    low = str(text or "").lower()
    out = []
    for rx, ind in SEGMENT_INDUSTRY:
        m = re.search(rx, low)
        if m:
            out.append((ind, rx, m.group(0)))
    return out


def segment_industry(name, product_lines=(), fallback=None):
    """(industry, how) for one reportable segment: its name first, then its product-line names. A segment
    named only by a region is the company's own business in that region."""
    if _REGION_NAME.match(str(name or "").strip()):
        return fallback, "regional segment: company industry"
    for text, how in ((name, "segment name"), (" ".join(product_lines or ()), "product lines")):
        hits = keyword_matches(text)
        if hits:
            return hits[0][0], f"{how} matches {hits[0][0]}"
    return fallback, "no keyword match: company industry"


# ── parent-sector compatibility: what a segment's industry may be, given the company it belongs to ──
PASS, REJECTED, UNAVAILABLE_MAPPING = "PASS", "REJECTED", "UNAVAILABLE"
# explicit evidence that a segment is itself a financial business: one of these stands as a whole word in its own text
_FINANCIAL_EVIDENCE = _kw("financial", "finance", "financing", "credit", "bank", "banking", "lending", "leasing", "insurance",
                          "reinsurance", "brokerage", "payments?", "cards?", "asset management", "wealth management")


def is_financial_industry(industry):
    return any(w in str(industry or "").lower() for w in _FINANCIAL_WORDS)


def industry_compatible(parent_industry, parent_is_financial, industry, segment_text):
    """(ok, reason): may a segment of this company carry this industry? A clearly non-financial parent's segment may
    carry a financial industry only on explicit evidence in the segment's own text (a financial word standing as a whole
    word): financial firms' debt is operating, so their EV/Sales and unlevered betas do not describe an operating
    business. No other parent/segment pair is restricted in this pass."""
    if not industry:
        return False, "no industry"
    parent_fin = bool(parent_is_financial) or is_financial_industry(parent_industry)
    if is_financial_industry(industry) and not parent_fin:
        if not re.search(_FINANCIAL_EVIDENCE, str(segment_text or "").lower()):
            return False, (f"financial industry '{industry}' for a segment of a non-financial company "
                           f"({parent_industry or 'unclassified'}) with no financial business named in its own text")
    return True, ""


def map_segment(segment_id, name, product_lines=(), parent_industry=None, parent_is_financial=False, use_product_lines=True):
    """SegmentIndustryMapping for one reportable segment. Fallback hierarchy after a rejection:
    1. the next keyword match in the segment's own text that is compatible with the parent (a validated segment industry);
    2. the company-level industry (compatible with itself);
    3. a broader compatible sector: not available (his datasets carry no sector level above the industry);
    4. mapping unavailable (no selected industry: the segment may feed neither value weights nor betas)."""
    text = str(name or "")
    m = {"segment_id": segment_id, "input_text": text, "selected_industry": None, "mapping_method": None,
         "matched_pattern": None, "matched_text": None, "parent_industry": parent_industry,
         "compatibility_status": UNAVAILABLE_MAPPING, "fallback_used": None, "rejected": [],
         "eligible_for_value_weight": False, "eligible_for_beta": False}

    def select(industry, method, pattern=None, matched=None, fallback=None):
        m.update(selected_industry=industry, mapping_method=method, matched_pattern=pattern, matched_text=matched,
                 compatibility_status=PASS, fallback_used=fallback, eligible_for_value_weight=True, eligible_for_beta=True)
        return m

    if _REGION_NAME.match(text.strip()):
        if parent_industry:
            return select(parent_industry, "regional segment: company industry", fallback="COMPANY_INDUSTRY")
        m["mapping_method"] = "regional segment: no company industry"
        return m
    sources = [(text, "segment name")]
    if use_product_lines and product_lines:
        sources.append((" ".join(str(x) for x in product_lines if x), "product lines"))
    for src, how in sources:
        for ind, rx, matched in keyword_matches(src):
            ok, why = industry_compatible(parent_industry, parent_is_financial, ind, src)
            if ok:
                return select(ind, f"{how} matches {ind}", rx, matched)
            m["rejected"].append({"industry": ind, "source": how, "matched_pattern": rx, "matched_text": matched,
                                  "compatibility_status": REJECTED, "reason": why})
    if parent_industry:
        return select(parent_industry, ("company industry (its keyword match was rejected as incompatible)" if m["rejected"]
                                        else "no keyword match: company industry"), fallback="COMPANY_INDUSTRY")
    m["mapping_method"] = "no compatible industry and no company industry"
    m["fallback_used"] = "UNAVAILABLE"
    return m


class IncompatibleMapping(Exception):
    """A segment's industry row was requested through a mapping that did not pass compatibility."""


def mapping_problem(mapping, parent_is_financial=False):
    """Why a SegmentIndustryMapping may not supply an industry row (None = usable). Its own status must be PASS and
    the pair it records is re-checked, so a record that merely says PASS cannot carry an incompatible industry."""
    mp = mapping or {}
    if mp.get("compatibility_status") != PASS or not mp.get("selected_industry"):
        return f"mapping status {mp.get('compatibility_status')}"
    ok, why = industry_compatible(mp.get("parent_industry"), parent_is_financial, mp.get("selected_industry"),
                                  mp.get("input_text"))
    return None if ok else why


def industry_row(mapping, region, use, parent_is_financial=False):
    """The industry statistics a mapping may supply for ``use`` ("value_weight": EV/Sales; "beta": unlevered beta). The
    ONE accessor the business mix reads, so a bad mapping cannot be amplified through weight and beta together."""
    why = mapping_problem(mapping, parent_is_financial)
    flag = {"value_weight": "eligible_for_value_weight", "beta": "eligible_for_beta"}[use]
    if why is None and not (mapping or {}).get(flag):
        why = f"mapping not eligible for {use}"
    if why:
        raise IncompatibleMapping(f"{(mapping or {}).get('segment_id') or (mapping or {}).get('input_text')}: {why}")
    ind = mapping["selected_industry"]
    return industry_stats(ind, region) or industry_stats(ind, "US") or {}


def business_mix(segments, region, company_industry, debt, market_cap, tax=0.25, parent_is_financial=False):
    """His business-mix beta: each business line's industry unlevered beta (cash-corrected), weighted
    by the ESTIMATED VALUE of the business (its revenue × its industry's EV/Sales, his weighting), and
    re-levered at the company's market D/E. Revenue weights are used only when an EV/Sales is missing,
    and the basis says so. Also returns the industry sales-to-capital of the mix (capital = Σ revenue /
    sales-to-capital) and each segment's industry benchmarks. None unless 2+ business lines.

    Every row reads its industry through a SegmentIndustryMapping that passed compatibility (industry_row): the
    same industry supplies the EV/Sales weight AND the beta, so an incompatible mapping is refused for both and the
    whole mix is withheld (the single-industry beta then applies)."""
    rows = []
    for g in segments or []:
        if _IGNORE_SEGMENTS.search(str(g.get("name") or "")) or not (g.get("revenue") or 0) > 0:
            continue
        if g.get("allocations") is not None:
            # canonical segments (segments.allocate): one row per industry allocation, fractions sum to 1
            allocs = [(a["industry"], a["basis"], float(a["fraction"]),
                       a.get("mapping") or {"segment_id": g.get("segment_id"), "input_text": g.get("name"),
                                            "selected_industry": a["industry"], "parent_industry": company_industry,
                                            "compatibility_status": PASS, "eligible_for_value_weight": True,
                                            "eligible_for_beta": True}) for a in g["allocations"]]
        else:
            if g.get("kind") == "geographic":
                continue
            mp = map_segment(g.get("segment_id"), g.get("name"), [p.get("name") for p in g.get("product_lines") or []],
                             company_industry, parent_is_financial)
            if not mp["selected_industry"]:
                return None
            allocs = [(mp["selected_industry"], mp["mapping_method"], 1.0, mp)]
        for ind, how, fr, mp in allocs:
            try:
                st_w = industry_row(mp, region, "value_weight", parent_is_financial)
                st_b = industry_row(mp, region, "beta", parent_is_financial)
            except IncompatibleMapping:
                return None              # never a precision blend over a mapping that failed compatibility
            rows.append({"name": g.get("name") + (f" ({ind})" if len(allocs) > 1 else ""), "segment_id": g.get("segment_id"),
                         "fraction": fr, "revenue": g["revenue"] * fr, "industry": ind, "match": how,
                         "unlevered_beta": st_b.get("unlevered_beta_cash") or st_b.get("unlevered_beta"),
                         "industry_sales_to_capital": st_b.get("sales_to_capital"), "industry_operating_margin": st_b.get("operating_margin"),
                         "industry_ev_sales": st_w.get("ev_sales"),
                         "mapping": {k: mp.get(k) for k in ("segment_id", "input_text", "selected_industry", "mapping_method",
                                                            "matched_pattern", "matched_text", "parent_industry",
                                                            "compatibility_status", "fallback_used",
                                                            "eligible_for_value_weight", "eligible_for_beta")}})
    if len(rows) < 2 or any(r["unlevered_beta"] is None for r in rows) or len({r["industry"] for r in rows}) < 2:
        return None          # one business, or several lines of the same business: the single-industry beta applies
    tot = sum(r["revenue"] for r in rows)
    by_value = all((r["industry_ev_sales"] or 0) > 0 for r in rows)
    for r in rows:
        r["revenue_weight"] = r["revenue"] / tot
        r["estimated_value"] = (r["revenue"] * r["industry_ev_sales"]) if by_value else None
    vtot = sum(r["estimated_value"] for r in rows) if by_value else None
    for r in rows:
        r["weight"] = (r["estimated_value"] / vtot) if by_value else r["revenue_weight"]
    bu = sum(r["weight"] * r["unlevered_beta"] for r in rows)
    s2c = [r for r in rows if r["industry_sales_to_capital"]]
    s2c_mix = (sum(r["revenue"] for r in s2c) / sum(r["revenue"] / r["industry_sales_to_capital"] for r in s2c)) if s2c else None
    de = (float(debt) / float(market_cap)) if (debt and market_cap and market_cap > 0) else 0.0
    return {"segments": rows, "unlevered_beta": bu, "beta": bu * (1.0 + (1.0 - tax) * min(max(de, 0.0), 5.0)),
            "sales_to_capital": s2c_mix, "weighting": "value" if by_value else "revenue",
            "weights": ("estimated value: segment revenue × its industry EV/Sales (his datasets)" if by_value else
                        "share of revenue (an industry EV/Sales is missing, so value weights could not be formed)")}


def business_mix_problems(mix_rows, company_industry, parent_is_financial=False):
    """[problem] for the rows of a business-mix beta: every industry it used must come through a compatible mapping
    (BUSINESS_MIX_BETA_VALID). A row with no mapping record is re-checked from its own industry and name."""
    bad = []
    for r in mix_rows or []:
        mp = r.get("mapping") or {"segment_id": r.get("segment_id"), "input_text": str(r.get("name") or "").split(" (")[0],
                                  "selected_industry": r.get("industry"), "parent_industry": company_industry,
                                  "compatibility_status": PASS}
        mp = dict(mp, selected_industry=r.get("industry"))          # the industry the row actually used
        why = mapping_problem(mp, parent_is_financial)
        if why:
            bad.append(f"{r.get('name')}: {why}")
    return bad


# Different economics (his reason for valuing a company business by business): reported operating
# margins far apart, or businesses whose industries carry very different risk.
SEGMENT_MARGIN_SPREAD = 0.20
SEGMENT_BETA_SPREAD = 0.25


def segment_build_evidence(filed_segments, mix_rows=None):
    """{required, kind, reason, low, high, low_label, high_label}. A segment-by-segment story is required
    when the company files two or more business lines whose reported operating margins span at least
    SEGMENT_MARGIN_SPREAD ("margin"), or whose industries' unlevered betas span at least
    SEGMENT_BETA_SPREAD ("risk"). The numbers are kept as fields so a report can cite them as claims."""
    segs = [g for g in filed_segments or [] if g.get("kind", "business_line") == "business_line"]
    if len(segs) < 2:
        return {"required": False, "kind": None, "reason": "fewer than two business lines filed"}
    m = [(g.get("name"), g["margin"]) for g in segs if g.get("margin") is not None]
    if len(m) >= 2:
        lo, hi = min(m, key=lambda x: x[1]), max(m, key=lambda x: x[1])
        if hi[1] - lo[1] >= SEGMENT_MARGIN_SPREAD:
            return {"required": True, "kind": "margin", "low": lo[1], "high": hi[1], "low_label": lo[0], "high_label": hi[0],
                    "reason": f"reported operating margins span {lo[1]*100:.0f}% ({lo[0]}) to {hi[1]*100:.0f}% ({hi[0]})"}
    # risk is compared between REPORTABLE SEGMENTS: a segment allocated to several industries (segments.allocate)
    # carries its fraction-weighted beta, so a product-line split inside one segment is not a second business
    per = {}
    for r in mix_rows or []:
        if r.get("unlevered_beta") is None:
            continue
        k = r.get("segment_id") or r.get("name") or id(r)
        a = per.setdefault(k, {"label": r.get("industry"), "w": 0.0, "bw": 0.0, "n": 0})
        fr = float(r.get("fraction") or 1.0)
        a["w"] += fr; a["bw"] += fr * r["unlevered_beta"]; a["n"] += 1
        if a["n"] > 1:
            a["label"] = str(r.get("name") or k).split(" (")[0] + " (mixed)"
    b = [(a["label"], a["bw"] / a["w"]) for a in per.values() if a["w"] > 0]
    if len(b) >= 2:
        lo, hi = min(b, key=lambda x: x[1]), max(b, key=lambda x: x[1])
        if hi[1] - lo[1] >= SEGMENT_BETA_SPREAD:
            return {"required": True, "kind": "risk", "low": lo[1], "high": hi[1], "low_label": lo[0], "high_label": hi[0],
                    "reason": f"industry risk differs: {lo[0]} unlevered beta {lo[1]:.2f} vs {hi[0]} {hi[1]:.2f}"}
    return {"required": False, "kind": None, "reason": "business lines have similar margins and industry risk"}


def segment_build_required(filed_segments, mix_rows=None):
    """(required, reason) — see ``segment_build_evidence``."""
    e = segment_build_evidence(filed_segments, mix_rows)
    return e["required"], e["reason"]