s-ttp commited on
Commit
3ad7d41
·
verified ·
1 Parent(s): a5d4d0f

Premium inputs: perimeter-changed trailing ROE/margin and distorted EPS growth add no differential

Browse files
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}; ``industry``: Damodaran industry stats row."""
 
 
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"]