aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/valuation.py553
1 files changed, 7 insertions, 546 deletions
diff --git a/components/valuation.py b/components/valuation.py
index 407538e..f0fbdb9 100644
--- a/components/valuation.py
+++ b/components/valuation.py
@@ -465,7 +465,6 @@ def _render_ratios(ticker: str):
peg = _r("pegRatioTTM") or (info.get("pegRatio") if info else None)
ev_ebt = _r("enterpriseValueMultipleTTM")
ev_rev = _r("evToSalesTTM")
- ev_ebit = _r("evToOperatingCashFlowTTM") # best proxy if direct unavailable
pb = _r("priceToBookRatioTTM")
ps = _r("priceToSalesRatioTTM")
fcf_yield_v = (fcf_ttm / market_cap) if fcf_ttm and market_cap and market_cap > 0 else None
@@ -679,7 +678,7 @@ def _render_ratios(ticker: str):
+ _kpi_spark("EV / Revenue", ev_rev, "x", "evToSalesTTM", None, invert=True)
+ _kpi_spark("P / Book", pb, "x", "priceToBookRatioTTM", sparks.get("pb"), invert=True)
+ _kpi_spark("P / FCF", p_fcf, "x", "peRatioTTM", None, invert=True)
- + _kpi_spark("FCF Yield", fcf_yield_v, "%", "dividendYieldTTM", None, invert=False)
+ + _kpi_spark("FCF Yield", fcf_yield_v, "%", None, None, invert=False)
)
# ── Assemble val rows ───────────────────────────────────────────────────
@@ -727,7 +726,7 @@ def _render_ratios(ticker: str):
cash_rows_html = (
'<div class="kr-mini head"><span>Metric</span><span class="r">Subject</span><span class="r">Peers</span><span class="r">Trend</span></div>'
- + _mini_row("FCF yield", fcf_yield_v, "%", _pm("dividendYieldTTM"), None)
+ + _mini_row("FCF yield", fcf_yield_v, "%", None, None)
+ _mini_row("Dividend yield", div_y, "%", _pm("dividendYieldTTM"), None)
+ _mini_row("Payout ratio", payout, "%", _pm("dividendPayoutRatioTTM"), None, good_low=True)
+ _mini_row("Buyback yield", buyback_yield, "%", None, None)
@@ -1213,531 +1212,6 @@ def _fmt_b(v_dollars: float) -> str:
return f"${b:.2f}B"
-def _build_dcf_canvas_html(
- ctx: dict,
- result: dict,
- wacc_pct: float,
- tg_pct: float,
- yrs: int,
- g_pct: float,
- ev_ebitda_price: float | None,
- ev_rev_price: float | None,
- pb_price: float | None,
-) -> str:
- iv = result["intrinsic_value_per_share"]
- market = float(ctx["current_price"] or 0)
- has_market = market > 0
-
- upside_pct = (iv - market) / market * 100 if has_market else 0.0
- is_pos = upside_pct >= 0
- gap = iv - market
-
- # Bridge
- ev_b = _fmt_b(result["enterprise_value"])
- net_debt_b = _fmt_b(abs(result["net_debt"]))
- other_claims_b = _fmt_b(ctx["preferred_equity"] + ctx["minority_interest"])
- equity_b = _fmt_b(result["equity_value"])
- total_debt_b = _fmt_b(ctx["total_debt"])
- cash_b = _fmt_b(ctx["cash_and_equivalents"])
- other_b_val = ctx["preferred_equity"] + ctx["minority_interest"]
-
- shares_b = ctx["shares"] / 1e9
- source_date = ctx["bridge_items"].get("source_date", "")
-
- # Forecast sequences (capped at yrs)
- discounted = result["discounted_fcfs"][:yrs]
- projected = result["projected_fcfs"][:yrs]
- tv_pv = result["terminal_value_pv"]
- terminal_fcf = projected[-1] * (1 + tg_pct / 100) if projected else 0.0
- disc_factors = [1.0 / (1 + wacc_pct / 100) ** (i + 1) for i in range(len(discounted))]
- disc_tv_factor = 1.0 / (1 + wacc_pct / 100) ** yrs
-
- # Plotly chart data
- bar_x = [f"Year {i + 1}" for i in range(len(discounted))] + ["Terminal"]
- bar_y = [v / 1e9 for v in discounted] + [tv_pv / 1e9]
- bar_colors = ["#243E5A"] * len(discounted) + ["#C2AA7A"]
- bar_line_colors = ["#1F3B5E"] * len(discounted) + ["#DCC79E"]
- bar_text = [_fmt_b(v) for v in discounted] + [_fmt_b(tv_pv)]
-
- plotly_data_json = json_for_script([{
- "type": "bar",
- "x": bar_x,
- "y": bar_y,
- "marker": {"color": bar_colors, "line": {"color": bar_line_colors, "width": 1}},
- "text": bar_text,
- "textposition": "outside",
- "textfont": {"family": "IBM Plex Mono", "size": 10, "color": "#C7C0AE"},
- "hovertemplate": "%{x}: %{text}<extra></extra>",
- "cliponaxis": False,
- }])
- plotly_layout_json = json_for_script({
- "paper_bgcolor": "#11151C",
- "plot_bgcolor": "#11151C",
- "margin": {"l": 48, "r": 8, "t": 28, "b": 36},
- "xaxis": {
- "gridcolor": "rgba(0,0,0,0)",
- "linecolor": "#232934",
- "tickfont": {"family": "IBM Plex Sans", "size": 11, "color": "#8E8676"},
- "fixedrange": True,
- },
- "yaxis": {
- "gridcolor": "#232934",
- "linecolor": "rgba(0,0,0,0)",
- "tickfont": {"family": "IBM Plex Mono", "size": 10, "color": "#8E8676"},
- "tickprefix": "$",
- "ticksuffix": "B",
- "fixedrange": True,
- "zeroline": False,
- },
- "bargap": 0.35,
- "showlegend": False,
- "uniformtext": {"mode": "hide", "minsize": 8},
- })
-
- data_json = json_for_script({
- "baseFcf": result["base_fcf"],
- "netDebt": result["net_debt"],
- "otherClaims": ctx["preferred_equity"] + ctx["minority_interest"],
- "shares": ctx["shares"],
- "market": float(ctx["current_price"] or 0),
- })
-
- # Verdict
- verdict_gradient = (
- "linear-gradient(110deg,transparent 35%,rgba(79,140,94,.07) 100%)"
- if is_pos else
- "linear-gradient(110deg,transparent 35%,rgba(181,73,75,.07) 100%)"
- )
- pill_cls = "pos" if is_pos else "neg"
- pill_arrow = "▲" if is_pos else "▼"
- pill_sign = "+" if is_pos else "−"
- pill_text = f"{pill_arrow} {pill_sign}{abs(upside_pct):.1f}% {'upside' if is_pos else 'downside'}"
- reading = "Constructive" if is_pos else "Cautious"
- gap_dir = "above" if gap >= 0 else "below"
-
- iv_str = f"${iv:,.2f}"
- market_str = f"${market:,.2f}" if has_market else "—"
- gap_str = f"${abs(gap):,.2f}"
-
- # Cash-flow table
- n = len(discounted)
- hdr_cells = "".join(f"<th>Yr {i + 1}</th>" for i in range(n)) + "<th>Terminal</th>"
- fcf_cells = "".join(f"<td>{_fmt_b(v)}</td>" for v in projected)
- fcf_cells += f'<td class="brass">{_fmt_b(terminal_fcf)}</td>'
- df_cells = "".join(f"<td>{disc_factors[i]:.3f}</td>" for i in range(n))
- df_cells += f"<td>{disc_tv_factor:.3f}</td>"
- pv_cells = "".join(f"<td>{_fmt_b(v)}</td>" for v in discounted)
- pv_cells += f'<td class="brass">{_fmt_b(tv_pv)}</td>'
-
- # Cross-check cells
- def cx_cell(cls, lbl, val_str, delta_pct, meta):
- if delta_pct is not None and has_market:
- dcls = "pos" if delta_pct >= 0 else "neg"
- dsign = "+" if delta_pct >= 0 else ""
- dhtml = f'<span class="delta {dcls}">{dsign}{delta_pct:.1f}% vs market</span>'
- else:
- dhtml = '<span class="delta na">—</span>'
- return (
- f'<div class="{cls}">'
- f'<span class="lbl">{lbl}</span>'
- f'<span class="v num">{val_str}</span>'
- f"{dhtml}"
- f'<span class="meta">{meta}</span>'
- f"</div>"
- )
-
- dcf_delta = upside_pct if has_market else None
- if dcf_delta is not None and has_market:
- dcf_dcls = "pos" if dcf_delta >= 0 else "neg"
- dcf_dsign = "+" if dcf_delta >= 0 else ""
- dcf_dhtml = f'<span id="cx-dcf-d" class="delta {dcf_dcls}">{dcf_dsign}{dcf_delta:.1f}% vs market</span>'
- else:
- dcf_dhtml = '<span id="cx-dcf-d" class="delta na">—</span>'
- cx_dcf = (
- f'<div class="va-cx-cell dcf">'
- f'<span class="lbl">DCF · THIS MODEL</span>'
- f'<span id="cx-dcf-v" class="v num">{iv_str}</span>'
- f"{dcf_dhtml}"
- f'<span class="meta">Firm-value DCF · {yrs}-yr explicit · WACC {wacc_pct:.1f}%</span>'
- f"</div>"
- )
-
- def _cx_multiple_cell(label, implied, market_multiple, mult_label):
- if implied is not None and has_market:
- delta = (implied - market) / market * 100
- val = f"${implied:,.2f}"
- meta = f"Market multiple {market_multiple:.1f}× · {mult_label}" if market_multiple else mult_label
- else:
- delta = None
- val = "—"
- meta = "Unavailable for this company"
- return cx_cell("va-cx-cell", label, val, delta, meta)
-
- cx_ev = _cx_multiple_cell(
- "EV / EBITDA", ev_ebitda_price,
- ctx.get("ev_ebitda_current") or 0, "based on current market multiple",
- )
- cx_rev = _cx_multiple_cell(
- "EV / REVENUE", ev_rev_price,
- ctx.get("ev_revenue_current") or 0, "based on current market multiple",
- )
- cx_pb = _cx_multiple_cell(
- "P / BOOK", pb_price,
- ctx.get("pb_current") or 0, "based on current market multiple",
- )
-
- # Recon gap cell color
- gap_color = "var(--positive)" if gap >= 0 else "var(--negative)"
- gap_sign = "+" if gap >= 0 else ""
- gap_display = f"{gap_sign}${gap:,.2f}" if has_market else "—"
- gap_pct_str = f"{upside_pct:.1f}% vs market" if has_market else "—"
-
- # Rail filing strings (static, Python-formatted)
- net_debt_raw = ctx["total_debt"] - ctx["cash_and_equivalents"]
- base_fcf_str = _fmt_b(result["base_fcf"])
- hist_growth_str = f"{result['growth_rate_used']*100:+.1f}%"
- net_debt_str = _fmt_b(net_debt_raw)
- shares_str = f"{ctx['shares']/1e9:.2f} B"
- net_debt_label = f"Net debt{(' · ' + source_date) if source_date else ''}"
-
- html = f"""<!DOCTYPE html>
-<html>
-<head>
-<meta charset="UTF-8">
-<link rel="preconnect" href="https://fonts.googleapis.com">
-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
-<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script>
-<style>{_DCF_CANVAS_CSS}
-/* 2-col inspector layout */
-.dcf-inspector{{display:grid;grid-template-columns:272px 1fr;min-height:100%;background:var(--ink-0)}}
-.dcf-rail{{padding:20px 16px 32px;border-right:1px solid var(--line-1);display:flex;flex-direction:column;gap:0;background:var(--ink-0)}}
-.dcf-canvas-inner{{display:flex;flex-direction:column;gap:24px;padding:24px 24px 48px}}
-/* Rail type */
-.dcf-eyebrow{{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600;line-height:1}}
-.dcf-title{{font-family:'EB Garamond',Georgia,serif;font-size:20px;font-weight:500;letter-spacing:-.01em;color:var(--fg-1);margin:6px 0 0;line-height:1.2}}
-.dcf-sub{{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);margin-top:6px;line-height:1.5}}
-.dcf-divider{{border:none;border-top:1px solid var(--line-1);margin:14px 0}}
-.dcf-filings-eyebrow{{font-family:var(--font-sans);font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg-3);font-weight:600;margin-bottom:10px}}
-.dcf-filing-row{{display:flex;justify-content:space-between;align-items:baseline;font-family:var(--font-mono);font-size:12px;color:var(--fg-2);margin-bottom:6px}}
-.dcf-filing-val{{color:var(--fg-1);font-variant-numeric:tabular-nums}}
-/* Rail sliders */
-.rail-sliders{{display:flex;flex-direction:column;gap:14px;margin-top:14px}}
-.rail-sl-item{{display:flex;flex-direction:column;gap:5px}}
-.rail-sl-head{{display:flex;justify-content:space-between;align-items:baseline}}
-.rail-sl-lbl{{font-family:var(--font-sans);font-size:12px;color:var(--fg-2)}}
-.rail-sl-val{{font-family:var(--font-mono);font-size:12px;color:var(--brass-bright);font-variant-numeric:tabular-nums}}
-.rail-warn{{font-family:var(--font-sans);font-size:11px;color:var(--warning);padding:6px 8px;background:var(--warning-bg);border-radius:4px;margin-top:4px}}
-.dcf-rail input[type=range]{{width:100%;-webkit-appearance:none;appearance:none;background:var(--ink-3);height:4px;border-radius:999px;cursor:pointer;outline:none}}
-.dcf-rail input[type=range]::-webkit-slider-thumb{{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;box-shadow:0 0 0 1px var(--brass-deep);cursor:pointer}}
-.dcf-rail input[type=range]::-moz-range-thumb{{width:14px;height:14px;border-radius:50%;background:var(--brass);border:2px solid #0B0E13;cursor:pointer;border:none}}
-.rail-sl-hint{{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--fg-4);margin-top:3px;letter-spacing:.02em}}
-.rail-actions{{display:flex;flex-direction:column;gap:8px;margin-top:16px}}
-.rail-btn{{font-family:var(--font-sans);font-size:12px;color:var(--fg-3);background:var(--ink-2);border:1px solid var(--line-2);border-radius:3px;padding:7px 12px;cursor:pointer;text-align:center;transition:color .15s,border-color .15s;width:100%}}
-.rail-btn:hover{{color:var(--fg-1);border-color:var(--line-3)}}
-.rail-btn[disabled]{{opacity:.4;cursor:not-allowed;pointer-events:none}}
-</style>
-</head>
-<body>
-<div class="dcf-inspector">
-
- <aside class="dcf-rail">
- <span class="dcf-eyebrow">Assumptions</span>
- <div class="dcf-title">3-stage DCF</div>
- <div class="dcf-sub">Firm-value DCF — projects free cash flow, discounts to today, bridges to equity per share.</div>
-
- <div class="rail-sliders">
- <div class="rail-sl-item">
- <div class="rail-sl-head">
- <span class="rail-sl-lbl">WACC (%)</span>
- <span class="rail-sl-val" id="wacc-disp">{wacc_pct:.2f}%</span>
- </div>
- <input type="range" id="sl-wacc" min="4" max="15" step="0.25" value="{wacc_pct}">
- <div class="rail-sl-hint"><span>4.0 aggressive</span><span>conservative 15.0</span></div>
- </div>
- <div class="rail-sl-item">
- <div class="rail-sl-head">
- <span class="rail-sl-lbl">Terminal growth (%)</span>
- <span class="rail-sl-val" id="tg-disp">{tg_pct:.1f}%</span>
- </div>
- <input type="range" id="sl-tg" min="0" max="5" step="0.1" value="{tg_pct}">
- <div class="rail-sl-hint"><span>0.0 conservative</span><span>aggressive 5.0</span></div>
- </div>
- <div class="rail-sl-item">
- <div class="rail-sl-head">
- <span class="rail-sl-lbl">Forecast horizon (yr)</span>
- <span class="rail-sl-val" id="yrs-disp">{yrs} yr</span>
- </div>
- <input type="range" id="sl-yrs" min="3" max="10" step="1" value="{yrs}">
- <div class="rail-sl-hint"><span>3 yr short</span><span>extended 10 yr</span></div>
- </div>
- <div class="rail-sl-item">
- <div class="rail-sl-head">
- <span class="rail-sl-lbl">FCF growth (%)</span>
- <span class="rail-sl-val" id="g-disp">{g_pct:.1f}%</span>
- </div>
- <input type="range" id="sl-g" min="-15" max="20" step="0.1" value="{g_pct}">
- <div class="rail-sl-hint"><span>-15 decline</span><span>growth +20</span></div>
- </div>
- </div>
- <div class="rail-warn" id="wacc-tg-warn" style="display:none">WACC must exceed terminal growth</div>
-
- <hr class="dcf-divider">
-
- <div class="dcf-filings-eyebrow">From the filings</div>
- <div class="dcf-filing-row"><span>Base FCF (TTM)</span><span class="dcf-filing-val">{base_fcf_str}</span></div>
- <div class="dcf-filing-row"><span>FCF · 5-yr median</span><span class="dcf-filing-val">{hist_growth_str}</span></div>
- <div class="dcf-filing-row"><span>{net_debt_label}</span><span class="dcf-filing-val">{net_debt_str}</span></div>
- <div class="dcf-filing-row"><span>Shares outstanding</span><span class="dcf-filing-val">{shares_str}</span></div>
-
- <div class="rail-actions">
- <button class="rail-btn" onclick="resetSliders()">Reset to defaults</button>
- <button class="rail-btn" disabled>Save scenario &middot; soon</button>
- </div>
- </aside>
-
- <div class="dcf-canvas-inner">
-
- <section class="va-verdict" style="--verdict-gradient:{verdict_gradient}">
- <div id="verdict-grad" style="position:absolute;inset:0;background:{verdict_gradient};pointer-events:none;z-index:0"></div>
- <div class="top">
- <div class="col">
- <span class="lbl">DCF Intrinsic Value</span>
- <span class="big num" id="iv-big">{iv_str}</span>
- <span class="sub">per share &middot; firm value method &middot; {yrs}-yr horizon</span>
- </div>
- <span class="arrow">vs</span>
- <div class="col" style="align-items:flex-end">
- <span class="lbl">Market Price</span>
- <span class="big market num">{market_str}</span>
- <span class="pill {pill_cls}" id="upside-pill">{pill_text}</span>
- </div>
- </div>
- <div class="band">
- <span>Reading &middot; DCF implies <span class="mono" id="gap-str">{gap_str}</span> <span id="gap-dir">{gap_dir}</span> the current market.</span>
- <span class="reading" id="reading-str">{reading}</span>
- </div>
- </section>
-
- <section class="va-projection">
- <div class="head">
- <h3>Enterprise value build &mdash; present value of FCFs + terminal</h3>
- <span class="units" id="chart-units">USD &middot; billions &middot; discounted at WACC {wacc_pct:.1f}%</span>
- </div>
- <div id="dcf-chart" style="width:100%;height:260px"></div>
- <table class="va-cf-table">
- <thead><tr id="cf-thead"><th></th>{hdr_cells}</tr></thead>
- <tbody>
- <tr id="cf-fcf"><td>Forecast FCF</td>{fcf_cells}</tr>
- <tr id="cf-df"><td>Discount factor</td>{df_cells}</tr>
- <tr class="total" id="cf-pv"><td>Present value</td>{pv_cells}</tr>
- </tbody>
- </table>
- </section>
-
- <section class="va-bridge">
- <div class="bhead">
- <h3>From enterprise to equity</h3>
- <span class="bdate">Balance-sheet bridge{(' &middot; ' + source_date) if source_date else ''}</span>
- </div>
- <div class="flow">
- <div class="node start"><span class="lbl">Enterprise value</span><span class="v num" id="ev-node-val">{ev_b}</span></div>
- <div class="op">&minus;<span class="sub">Net debt</span></div>
- <div class="node"><span class="lbl">Net debt</span><span class="v num">{net_debt_b}</span></div>
- <div class="op">&minus;<span class="sub">Other claims</span></div>
- <div class="node"><span class="lbl">Other claims</span><span class="v num">{other_claims_b}</span></div>
- <div class="op">=</div>
- <div class="node result"><span class="lbl">Equity value</span><span class="v num" id="equity-node-val">{equity_b}</span></div>
- </div>
- <div class="bfoot">
- <span>Total debt {total_debt_b}</span>
- <span>&middot;</span>
- <span>Cash &amp; equiv. {cash_b}</span>
- <span>&middot;</span>
- <span>Preferred + minority {_fmt_b(other_b_val)}</span>
- </div>
- </section>
-
- <section class="va-recon">
- <div class="cell intrinsic">
- <span class="lbl">Intrinsic &middot; Per Share</span>
- <span class="v num" id="recon-iv">{iv_str}</span>
- <span class="sub">Equity value &divide; shares</span>
- </div>
- <div class="cell">
- <span class="lbl">Market &middot; Last</span>
- <span class="v num">{market_str}</span>
- <span class="sub">&nbsp;</span>
- </div>
- <div class="cell">
- <span class="lbl">Gap</span>
- <span class="v num" id="recon-gap" style="color:{gap_color}">{gap_display}</span>
- <span class="sub" id="recon-gap-pct">{gap_pct_str}</span>
- </div>
- <div class="cell">
- <span class="lbl">Shares Outstanding</span>
- <span class="v num">{shares_b:.2f} B</span>
- <span class="sub">diluted</span>
- </div>
- </section>
-
- <section class="va-cx">
- <div class="va-cx-head">
- <h3>Cross-check against the multiples</h3>
- <span class="hint">Same business, different lenses &middot; implied per-share</span>
- </div>
- <div class="va-cx-grid">
- {cx_dcf}
- {cx_ev}
- {cx_rev}
- {cx_pb}
- </div>
- </section>
-
- <div class="va-foot">
- <span>Firm-value DCF &middot; enterprise value bridged to equity using debt &amp; cash from the most recent balance sheet. Negative-FCF years are excluded from the base; terminal value uses Gordon Growth Model.</span>
- <a href="#">Methodology &amp; sources &nearr;</a>
- </div>
-
- </div>
-</div>
-<script>
-var D = {data_json};
-var LAYOUT = {plotly_layout_json};
-var INIT_WACC = {wacc_pct};
-var INIT_TG = {tg_pct};
-var INIT_YRS = {yrs};
-var INIT_G = {g_pct};
-
-function resetSliders() {{
- document.getElementById('sl-wacc').value = INIT_WACC;
- document.getElementById('sl-tg').value = INIT_TG;
- document.getElementById('sl-yrs').value = INIT_YRS;
- document.getElementById('sl-g').value = INIT_G;
- update();
-}}
-
-function fB(n) {{ var b=n/1e9; return Math.abs(b)>=1000?'$'+(b/1000).toFixed(2)+'T':'$'+b.toFixed(2)+'B'; }}
-function fS(n) {{ return '$'+n.toLocaleString('en-US',{{minimumFractionDigits:2,maximumFractionDigits:2}}); }}
-
-function runDCF(wacc, tg, yrs, g) {{
- g = Math.max(-0.5, Math.min(0.5, g));
- var fcfs=[], dfs=[], pvs=[];
- for (var i=1; i<=yrs; i++) {{
- var f = D.baseFcf * Math.pow(1+g, i);
- var df = 1/Math.pow(1+wacc, i);
- fcfs.push(f); dfs.push(df); pvs.push(f*df);
- }}
- var pvSum = pvs.reduce(function(a,b){{return a+b;}},0);
- var termFcf = fcfs[yrs-1]*(1+tg);
- var tvNom = termFcf/(wacc-tg);
- var tvDf = 1/Math.pow(1+wacc,yrs);
- var tvPv = tvNom*tvDf;
- var ev = pvSum+tvPv;
- var equity = ev-D.netDebt-D.otherClaims;
- return {{fcfs:fcfs,dfs:dfs,pvs:pvs,termFcf:termFcf,tvDf:tvDf,tvPv:tvPv,ev:ev,equity:equity,iv:equity/D.shares}};
-}}
-
-function setText(id,t){{var e=document.getElementById(id);if(e)e.textContent=t;}}
-function setHtml(id,h){{var e=document.getElementById(id);if(e)e.innerHTML=h;}}
-
-function update() {{
- var wacc=+document.getElementById('sl-wacc').value/100;
- var tg=+document.getElementById('sl-tg').value/100;
- var yrs=+document.getElementById('sl-yrs').value;
- var g=+document.getElementById('sl-g').value/100;
-
- setText('wacc-disp',(wacc*100).toFixed(2)+'%');
- setText('tg-disp',(tg*100).toFixed(1)+'%');
- setText('yrs-disp',yrs+' yr');
- setText('g-disp',(g*100).toFixed(1)+'%');
-
- var warn=document.getElementById('wacc-tg-warn');
- if (wacc<=tg) {{ warn.style.display='block'; return; }}
- warn.style.display='none';
-
- var r=runDCF(wacc,tg,yrs,g);
- var iv=r.iv, market=D.market, gap=iv-market;
- var isPos=iv>=market, upside=market>0?(iv-market)/market*100:0;
-
- setText('iv-big',fS(iv));
- var pill=document.getElementById('upside-pill');
- if(pill){{
- var arr=isPos?'▲':'▼', sign=isPos?'+':'−';
- pill.textContent=arr+' '+sign+Math.abs(upside).toFixed(1)+'% '+(isPos?'upside':'downside');
- pill.className='pill '+(isPos?'pos':'neg');
- }}
- var grad=document.getElementById('verdict-grad');
- if(grad) grad.style.background=isPos
- ?'linear-gradient(110deg,transparent 35%,rgba(79,140,94,.07) 100%)'
- :'linear-gradient(110deg,transparent 35%,rgba(181,73,75,.07) 100%)';
- setText('gap-str','$'+Math.abs(gap).toFixed(2));
- setText('gap-dir',gap>=0?'above':'below');
- setText('reading-str',isPos?'Constructive':'Cautious');
- setText('chart-units','USD · billions · discounted at WACC '+(wacc*100).toFixed(1)+'%');
-
- var thead=document.getElementById('cf-thead');
- var tfcf=document.getElementById('cf-fcf');
- var tdf=document.getElementById('cf-df');
- var tpv=document.getElementById('cf-pv');
- if(thead){{
- var hh='<th></th>',fh='<td>Forecast FCF</td>',dh='<td>Discount factor</td>',ph='<td>Present value</td>';
- for(var i=0;i<yrs;i++){{
- hh+='<th>Yr '+(i+1)+'</th>';
- fh+='<td>'+fB(r.fcfs[i])+'</td>';
- dh+='<td>'+r.dfs[i].toFixed(3)+'</td>';
- ph+='<td>'+fB(r.pvs[i])+'</td>';
- }}
- hh+='<th>Terminal</th>';
- fh+='<td class="brass">'+fB(r.termFcf)+'</td>';
- dh+='<td>'+r.tvDf.toFixed(3)+'</td>';
- ph+='<td class="brass">'+fB(r.tvPv)+'</td>';
- thead.innerHTML=hh; tfcf.innerHTML=fh; tdf.innerHTML=dh; tpv.innerHTML=ph;
- }}
-
- var bx=[],by=[],bc=[],blc=[],bt=[];
- for(var i=0;i<yrs;i++){{
- bx.push('Year '+(i+1)); by.push(r.pvs[i]/1e9);
- bc.push('#243E5A'); blc.push('#1F3B5E'); bt.push(fB(r.pvs[i]));
- }}
- bx.push('Terminal'); by.push(r.tvPv/1e9);
- bc.push('#C2AA7A'); blc.push('#DCC79E'); bt.push(fB(r.tvPv));
- Plotly.react('dcf-chart',[{{
- type:'bar',x:bx,y:by,
- marker:{{color:bc,line:{{color:blc,width:1}}}},
- text:bt,textposition:'outside',
- textfont:{{family:'IBM Plex Mono',size:10,color:'#C7C0AE'}},
- hovertemplate:'%{{x}}: %{{text}}<extra></extra>',
- cliponaxis:false
- }}],LAYOUT);
-
- setText('ev-node-val',fB(r.ev));
- setText('equity-node-val',fB(r.equity));
- setText('recon-iv',fS(iv));
- var gapEl=document.getElementById('recon-gap');
- if(gapEl){{gapEl.textContent=(gap>=0?'+$':'-$')+Math.abs(gap).toFixed(2);gapEl.style.color=gap>=0?'var(--positive)':'var(--negative)';}}
- setText('recon-gap-pct',market>0?upside.toFixed(1)+'% vs market':'—');
- setText('cx-dcf-v',fS(iv));
- if(market>0){{
- var dd=upside,dcls=dd>=0?'pos':'neg',dsign=dd>=0?'+':'';
- setHtml('cx-dcf-d','<span class="delta '+dcls+'">'+dsign+dd.toFixed(1)+'% vs market</span>');
- }}
-}}
-
-['sl-wacc','sl-tg','sl-yrs','sl-g'].forEach(function(id){{
- document.getElementById(id).addEventListener('input',update);
-}});
-
-// Initial chart render
-var data = {plotly_data_json};
-Plotly.newPlot('dcf-chart', data, LAYOUT, {{displayModeBar:false,responsive:true}});
-</script>
-</body>
-</html>"""
-
- return html
-
-
def _build_multiples_canvas_html(ctx: dict) -> str:
market = float(ctx["current_price"] or 0)
shares = float(ctx["shares"] or 0)
@@ -2700,22 +2174,11 @@ def _render_dcf_model(ctx: dict):
components.html(canvas_html, height=1500, scrolling=False)
-def _render_all_multiples(ctx: dict):
- """Render all three multiples methods side-by-side in a single HTML canvas.
-
- Three lenses (EV/EBITDA, EV/Revenue, P/Book) are shown in a math-flow
- comparison grid. All computation and slider interactivity happens client-side
- in JS. No Streamlit sliders or rail column — one full-width components.html()
- call only.
- """
+def _render_multiples_model(ctx: dict):
doc = _build_multiples_canvas_html(ctx)
components.html(doc, height=1900, scrolling=False)
-def _render_multiples_model(ctx: dict):
- _render_all_multiples(ctx)
-
-
def _render_ev_ebitda_model(ctx: dict):
st.markdown("**EV/EBITDA Valuation**")
st.caption(
@@ -3375,7 +2838,7 @@ def _render_comps(ticker: str):
"nPeers": n_peers,
})
- total_height = 2600 + max(0, n_peers - 10) * 54
+ total_height = max(1900, 1500 + n_peers * 80)
ctx_html = (
'<div class="val-ctx">'
@@ -4434,6 +3897,9 @@ def _render_historical_ratios(ticker: str):
info = get_company_info(ticker)
with st.spinner("Loading historical ratios…"):
hist_rows = get_historical_ratios(ticker, limit=10)
+ peers_raw = get_peers(ticker)
+ peers = [p for p in (peers_raw or []) if p.upper() != ticker.upper()][:6]
+ peer_ratios_list = get_ratios_for_tickers(peers) if peers else []
if not hist_rows:
st.info("Historical ratio data unavailable.")
return
@@ -4443,11 +3909,6 @@ def _render_historical_ratios(ticker: str):
y = str(r.get("date", ""))[:4]
periods.append("FY" + y[2:] if len(y) == 4 else y)
- # ── Sector median data from TTM peer ratios ───────────────────────────────
- peers_raw = get_peers(ticker)
- peers = [p for p in (peers_raw or []) if p.upper() != ticker.upper()][:6]
- peer_ratios_list = get_ratios_for_tickers(peers) if peers else []
-
def _peer_median(field_ttm):
vals = []
for pr in peer_ratios_list: