aboutsummaryrefslogtreecommitdiff
path: root/components/valuation.py
blob: 6549d074a4df6b522cf175f6df45482f2781e3dc (plain)
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
"""Valuation panel — key ratios, DCF model, comparable companies."""
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from services.data_service import get_company_info, get_free_cash_flow_series
from services.fmp_service import get_key_ratios, get_peers, get_ratios_for_tickers
from services.valuation_service import run_dcf
from utils.formatters import fmt_ratio, fmt_pct, fmt_large, fmt_currency


def render_valuation(ticker: str):
    tab_ratios, tab_dcf, tab_comps = st.tabs(["Key Ratios", "DCF Model", "Comps"])

    with tab_ratios:
        _render_ratios(ticker)

    with tab_dcf:
        _render_dcf(ticker)

    with tab_comps:
        _render_comps(ticker)


# ── Key Ratios ───────────────────────────────────────────────────────────────

def _render_ratios(ticker: str):
    ratios = get_key_ratios(ticker)
    info = get_company_info(ticker)

    if not ratios and not info:
        st.info("Ratio data unavailable. Check your FMP API key.")
        return

    # Prefer FMP ratios, fall back to yfinance info
    def r(fmp_key, yf_key=None, fmt=fmt_ratio):
        val = ratios.get(fmp_key) if ratios else None
        if val is None and yf_key and info:
            val = info.get(yf_key)
        return fmt(val) if val is not None else "—"

    rows = [
        ("Valuation", [
            ("P/E (TTM)", r("peRatioTTM", "trailingPE")),
            ("Forward P/E", r("priceEarningsRatioTTM", "forwardPE")),
            ("P/S (TTM)", r("priceToSalesRatioTTM", "priceToSalesTrailing12Months")),
            ("P/B", r("priceToBookRatioTTM", "priceToBook")),
            ("EV/EBITDA", r("enterpriseValueMultipleTTM", "enterpriseToEbitda")),
            ("EV/Revenue", r("evToSalesTTM", "enterpriseToRevenue")),
        ]),
        ("Profitability", [
            ("Gross Margin", r("grossProfitMarginTTM", "grossMargins", fmt_pct)),
            ("Operating Margin", r("operatingProfitMarginTTM", "operatingMargins", fmt_pct)),
            ("Net Margin", r("netProfitMarginTTM", "profitMargins", fmt_pct)),
            ("ROE", r("returnOnEquityTTM", "returnOnEquity", fmt_pct)),
            ("ROA", r("returnOnAssetsTTM", "returnOnAssets", fmt_pct)),
            ("ROIC", r("returnOnInvestedCapitalTTM", fmt=fmt_pct)),
        ]),
        ("Leverage & Liquidity", [
            ("Debt/Equity", r("debtEquityRatioTTM", "debtToEquity")),
            ("Current Ratio", r("currentRatioTTM", "currentRatio")),
            ("Quick Ratio", r("quickRatioTTM", "quickRatio")),
            ("Interest Coverage", r("interestCoverageTTM")),
            ("Dividend Yield", r("dividendYieldTTM", "dividendYield", fmt_pct)),
            ("Payout Ratio", r("payoutRatioTTM", "payoutRatio", fmt_pct)),
        ]),
    ]

    for section_name, metrics in rows:
        st.markdown(f"**{section_name}**")
        cols = st.columns(6)
        for col, (label, val) in zip(cols, metrics):
            col.metric(label, val)
        st.write("")


# ── DCF Model ────────────────────────────────────────────────────────────────

def _render_dcf(ticker: str):
    info = get_company_info(ticker)
    shares = info.get("sharesOutstanding") or info.get("floatShares")
    current_price = info.get("currentPrice") or info.get("regularMarketPrice")

    if not shares:
        st.info("Shares outstanding not available — DCF cannot be computed.")
        return

    fcf_series = get_free_cash_flow_series(ticker)
    if fcf_series.empty:
        st.info("Free cash flow data unavailable.")
        return

    st.markdown("**Assumptions**")
    col1, col2, col3 = st.columns(3)
    with col1:
        wacc = st.slider("WACC (%)", min_value=5.0, max_value=20.0, value=10.0, step=0.5) / 100
    with col2:
        terminal_growth = st.slider("Terminal Growth (%)", min_value=0.5, max_value=5.0, value=2.5, step=0.5) / 100
    with col3:
        projection_years = st.slider("Projection Years", min_value=3, max_value=10, value=5, step=1)

    result = run_dcf(
        fcf_series=fcf_series,
        shares_outstanding=shares,
        wacc=wacc,
        terminal_growth=terminal_growth,
        projection_years=projection_years,
    )

    if not result:
        st.warning("Insufficient data to run DCF model.")
        return

    iv = result["intrinsic_value_per_share"]

    # ── Summary metrics ──────────────────────────────────────────────────────
    m1, m2, m3, m4 = st.columns(4)
    m1.metric("Intrinsic Value / Share", fmt_currency(iv))
    if current_price:
        upside = (iv - current_price) / current_price
        m2.metric("Current Price", fmt_currency(current_price))
        m3.metric("Upside / Downside", f"{upside * 100:+.1f}%", delta=f"{upside * 100:+.1f}%")
    m4.metric("FCF Growth Used", f"{result['growth_rate_used'] * 100:.1f}%")

    st.write("")

    # ── Waterfall chart ───────────────────────────────────────────────────────
    years = [f"Year {y}" for y in result["years"]]
    discounted = result["discounted_fcfs"]
    terminal_pv = result["terminal_value_pv"]

    bar_labels = years + ["Terminal Value"]
    bar_values = discounted + [terminal_pv]
    bar_colors = ["#4F8EF7"] * len(years) + ["#F7A24F"]

    fig = go.Figure(
        go.Bar(
            x=bar_labels,
            y=[v / 1e9 for v in bar_values],
            marker_color=bar_colors,
            text=[f"${v / 1e9:.2f}B" for v in bar_values],
            textposition="outside",
        )
    )
    fig.update_layout(
        title="PV of Projected FCFs + Terminal Value (Billions)",
        yaxis_title="USD (Billions)",
        plot_bgcolor="rgba(0,0,0,0)",
        paper_bgcolor="rgba(0,0,0,0)",
        margin=dict(l=0, r=0, t=40, b=0),
        height=360,
    )
    st.plotly_chart(fig, use_container_width=True)


# ── Comps Table ──────────────────────────────────────────────────────────────

def _render_comps(ticker: str):
    peers = get_peers(ticker)
    if not peers:
        st.info("No comparable companies found. Check your FMP API key.")
        return

    # Include the subject ticker
    all_tickers = [ticker.upper()] + [p for p in peers[:9] if p != ticker.upper()]

    with st.spinner("Loading comps..."):
        ratios_list = get_ratios_for_tickers(all_tickers)

    if not ratios_list:
        st.info("Could not load ratios for peer companies.")
        return

    display_cols = {
        "symbol": "Ticker",
        "peRatioTTM": "P/E",
        "priceToSalesRatioTTM": "P/S",
        "priceToBookRatioTTM": "P/B",
        "enterpriseValueMultipleTTM": "EV/EBITDA",
        "netProfitMarginTTM": "Net Margin",
        "returnOnEquityTTM": "ROE",
        "debtEquityRatioTTM": "D/E",
    }

    df = pd.DataFrame(ratios_list)
    available = [c for c in display_cols if c in df.columns]
    df = df[available].rename(columns=display_cols)

    # Format numeric columns
    pct_cols = {"Net Margin", "ROE"}
    for col in df.columns:
        if col == "Ticker":
            continue
        if col in pct_cols:
            df[col] = df[col].apply(lambda v: fmt_pct(v) if v is not None else "—")
        else:
            df[col] = df[col].apply(lambda v: fmt_ratio(v) if v is not None else "—")

    # Highlight subject ticker row
    def highlight_subject(row):
        if row["Ticker"] == ticker.upper():
            return ["background-color: rgba(79,142,247,0.15)"] * len(row)
        return [""] * len(row)

    st.dataframe(
        df.style.apply(highlight_subject, axis=1),
        use_container_width=True,
        hide_index=True,
    )