Premium inputs: perimeter-changed trailing ROE/margin and distorted EPS growth add no differential
Browse files- engine/predict_yearly.py +15 -2
- engine/pricing_premium.py +10 -2
- engine/tests/test_pricing_premium.py +10 -0
engine/predict_yearly.py
CHANGED
|
@@ -3917,11 +3917,23 @@ def derive_fair_value(current_price, pred_fund, data, state=None):
|
|
| 3917 |
_dmi = data.get("damodaran_inputs") or {}
|
| 3918 |
_ind_st = (_ddp.industry_stats(_dmi.get("industry"), _dmi.get("region") or "US")
|
| 3919 |
or _ddp.industry_stats(_dmi.get("industry"), "US")) if _dmi.get("industry") else None
|
| 3920 |
-
_cg, _cg_src = None, None
|
|
|
|
| 3921 |
if state is not None:
|
| 3922 |
_f1, _f2 = state.value("consensus.eps_fy1"), state.value("consensus.eps_fy2")
|
| 3923 |
if _f1 and _f2 and _f1 > 0:
|
| 3924 |
_cg, _cg_src = _f2 / _f1 - 1.0, "sell-side consensus EPS, next fiscal year over this one (FY2 ÷ FY1)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3925 |
if _cg is None and pred_fund.get("eps_growth") is not None:
|
| 3926 |
_cg, _cg_src = pred_fund.get("eps_growth"), "reconciled engine forecast EPS growth (next twelve months)"
|
| 3927 |
_rf_p = canonical_risk_free(data)
|
|
@@ -3934,7 +3946,8 @@ def derive_fair_value(current_price, pred_fund, data, state=None):
|
|
| 3934 |
else:
|
| 3935 |
_prem = _ppm.industry_premiums({"growth": _cg, "growth_source": _cg_src,
|
| 3936 |
"roe": safe_float(overview.get("ReturnOnEquityTTM")),
|
| 3937 |
-
"beta": safe_float(_dmi.get("beta")), "net_margin": safe_float(overview.get("ProfitMargin"))
|
|
|
|
| 3938 |
_ind_st, _rf_p, _erp_p, _rf_p)
|
| 3939 |
_regime = _ppm.historical_regime_adjustment()
|
| 3940 |
# Damodaran lifecycle stage → dynamic method weighting in the blend below.
|
|
|
|
| 3917 |
_dmi = data.get("damodaran_inputs") or {}
|
| 3918 |
_ind_st = (_ddp.industry_stats(_dmi.get("industry"), _dmi.get("region") or "US")
|
| 3919 |
or _ddp.industry_stats(_dmi.get("industry"), "US")) if _dmi.get("industry") else None
|
| 3920 |
+
_cg, _cg_src, _nc = None, None, []
|
| 3921 |
+
_g_ok, _trl_ok = True, True
|
| 3922 |
if state is not None:
|
| 3923 |
_f1, _f2 = state.value("consensus.eps_fy1"), state.value("consensus.eps_fy2")
|
| 3924 |
if _f1 and _f2 and _f1 > 0:
|
| 3925 |
_cg, _cg_src = _f2 / _f1 - 1.0, "sell-side consensus EPS, next fiscal year over this one (FY2 ÷ FY1)"
|
| 3926 |
+
_r1, _r2 = state.value("consensus.revenue_fy1"), state.value("consensus.revenue_fy2")
|
| 3927 |
+
_rg = (_r2 / _r1 - 1.0) if (_r1 and _r2 and _r1 > 0) else None
|
| 3928 |
+
# earnings expected to shrink while revenue grows: this year's EPS carries items not expected to recur,
|
| 3929 |
+
# so the ratio is not a growth rate
|
| 3930 |
+
if _rg is not None and _cg < 0 < _rg:
|
| 3931 |
+
_g_ok = False
|
| 3932 |
+
_nc.append(f"growth: consensus EPS {_cg:+.1%} vs revenue {_rg:+.1%} (non-recurring items in this year's EPS)")
|
| 3933 |
+
# a business separated inside the trailing window: trailing ROE and margin mix two perimeters
|
| 3934 |
+
if (state.historical.get("perimeter") or {}).get("event"):
|
| 3935 |
+
_trl_ok = False
|
| 3936 |
+
_nc.append("ROE and net margin: trailing figures include a separated business")
|
| 3937 |
if _cg is None and pred_fund.get("eps_growth") is not None:
|
| 3938 |
_cg, _cg_src = pred_fund.get("eps_growth"), "reconciled engine forecast EPS growth (next twelve months)"
|
| 3939 |
_rf_p = canonical_risk_free(data)
|
|
|
|
| 3946 |
else:
|
| 3947 |
_prem = _ppm.industry_premiums({"growth": _cg, "growth_source": _cg_src,
|
| 3948 |
"roe": safe_float(overview.get("ReturnOnEquityTTM")),
|
| 3949 |
+
"beta": safe_float(_dmi.get("beta")), "net_margin": safe_float(overview.get("ProfitMargin")),
|
| 3950 |
+
"growth_comparable": _g_ok, "trailing_comparable": _trl_ok, "not_comparable": _nc},
|
| 3951 |
_ind_st, _rf_p, _erp_p, _rf_p)
|
| 3952 |
_regime = _ppm.historical_regime_adjustment()
|
| 3953 |
# Damodaran lifecycle stage → dynamic method weighting in the blend below.
|
engine/pricing_premium.py
CHANGED
|
@@ -65,9 +65,16 @@ def justified_pe(g, roe, r, g_s, r_s, roe_s, n=N_HIGH_GROWTH):
|
|
| 65 |
def industry_premiums(company, industry, rf, erp, g_stable):
|
| 66 |
"""{'pe': {...}, 'ps': {...}} — the industry-relative premiums with every input and difference recorded.
|
| 67 |
|
| 68 |
-
``company``: {growth, growth_source, roe, beta, net_margin
|
|
|
|
|
|
|
| 69 |
out = {}
|
| 70 |
ind = industry or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
need = {"industry growth": ind.get("expected_growth_5y"), "industry ROE": ind.get("roe"),
|
| 72 |
"industry beta": ind.get("levered_beta"), "risk-free rate": rf, "ERP": erp}
|
| 73 |
missing = [k for k, v in need.items() if v is None]
|
|
@@ -89,7 +96,8 @@ def industry_premiums(company, industry, rf, erp, g_stable):
|
|
| 89 |
pe_i = justified_pe(g_i, roe_i, r_i, g_s, r_s, roe_i)
|
| 90 |
rel = {"relative_growth": g_c - g_i, "relative_roe": roe_c - roe_i, "relative_cost_of_equity": r_c - r_i}
|
| 91 |
inputs = {"company": {"growth": g_c, "growth_source": company.get("growth_source"), "roe": roe_c,
|
| 92 |
-
"roe_source": "historical.roe_ttm" if (croe is not None and croe > 0) else "industry ROE (company ROE unusable)",
|
|
|
|
| 93 |
"cost_of_equity": r_c, "beta": b_c},
|
| 94 |
"industry": {"growth": g_i, "growth_source": "Damodaran pedata: expected growth, next 5 years", "roe": roe_i,
|
| 95 |
"roe_source": "Damodaran roe: ROE (unadjusted)", "cost_of_equity": r_i, "beta": float(ind["levered_beta"]),
|
|
|
|
| 65 |
def industry_premiums(company, industry, rf, erp, g_stable):
|
| 66 |
"""{'pe': {...}, 'ps': {...}} — the industry-relative premiums with every input and difference recorded.
|
| 67 |
|
| 68 |
+
``company``: {growth, growth_source, roe, beta, net_margin, growth_comparable, trailing_comparable, not_comparable};
|
| 69 |
+
``industry``: Damodaran industry stats row. An input marked not comparable takes the industry's value, so it adds
|
| 70 |
+
no differential (a company is never rewarded or penalized for a figure that does not describe it)."""
|
| 71 |
out = {}
|
| 72 |
ind = industry or {}
|
| 73 |
+
company = dict(company)
|
| 74 |
+
if company.get("growth_comparable") is False and ind.get("expected_growth_5y") is not None:
|
| 75 |
+
company["growth"], company["growth_source"] = ind["expected_growth_5y"], "industry value (company growth not comparable)"
|
| 76 |
+
if company.get("trailing_comparable") is False:
|
| 77 |
+
company["roe"], company["net_margin"] = None, ind.get("net_margin")
|
| 78 |
need = {"industry growth": ind.get("expected_growth_5y"), "industry ROE": ind.get("roe"),
|
| 79 |
"industry beta": ind.get("levered_beta"), "risk-free rate": rf, "ERP": erp}
|
| 80 |
missing = [k for k, v in need.items() if v is None]
|
|
|
|
| 96 |
pe_i = justified_pe(g_i, roe_i, r_i, g_s, r_s, roe_i)
|
| 97 |
rel = {"relative_growth": g_c - g_i, "relative_roe": roe_c - roe_i, "relative_cost_of_equity": r_c - r_i}
|
| 98 |
inputs = {"company": {"growth": g_c, "growth_source": company.get("growth_source"), "roe": roe_c,
|
| 99 |
+
"roe_source": "historical.roe_ttm" if (croe is not None and croe > 0) else "industry ROE (company ROE unusable or not comparable)",
|
| 100 |
+
"not_comparable": company.get("not_comparable") or [],
|
| 101 |
"cost_of_equity": r_c, "beta": b_c},
|
| 102 |
"industry": {"growth": g_i, "growth_source": "Damodaran pedata: expected growth, next 5 years", "roe": roe_i,
|
| 103 |
"roe_source": "Damodaran roe: ROE (unadjusted)", "cost_of_equity": r_i, "beta": float(ind["levered_beta"]),
|
engine/tests/test_pricing_premium.py
CHANGED
|
@@ -140,3 +140,13 @@ def test_own_history_moves_the_target_only_through_its_own_weight():
|
|
| 140 |
w = la["sector_weight"]
|
| 141 |
assert lb["own_history"] == pytest.approx(2 * la["own_history"], rel=1e-6)
|
| 142 |
assert lb["target_multiple"] - la["target_multiple"] == pytest.approx((lb["own_history"] - la["own_history"]) * (1 - w), rel=1e-6)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
w = la["sector_weight"]
|
| 141 |
assert lb["own_history"] == pytest.approx(2 * la["own_history"], rel=1e-6)
|
| 142 |
assert lb["target_multiple"] - la["target_multiple"] == pytest.approx((lb["own_history"] - la["own_history"]) * (1 - w), rel=1e-6)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_inputs_that_do_not_describe_the_company_add_no_differential():
|
| 146 |
+
base = PP.industry_premiums(_like_industry(), IND, 0.042, 0.0409, 0.042)["pe"]["value"]
|
| 147 |
+
distorted = PP.industry_premiums(_like_industry(growth=-0.05, growth_comparable=False), IND, 0.042, 0.0409, 0.042)
|
| 148 |
+
assert distorted["pe"]["value"] == pytest.approx(base)
|
| 149 |
+
spun = PP.industry_premiums(_like_industry(roe=0.45, net_margin=0.40, trailing_comparable=False,
|
| 150 |
+
not_comparable=["ROE and net margin: separated business"]), IND, 0.042, 0.0409, 0.042)
|
| 151 |
+
assert spun["pe"]["value"] == pytest.approx(base) and spun["ps"]["value"] == pytest.approx(base)
|
| 152 |
+
assert spun["pe"]["inputs"]["company"]["not_comparable"]
|