Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import datetime, requests, pandas as pd, numpy as np | |
| import plotly.graph_objects as go | |
| import os | |
| # ----- Global Configuration ----- | |
| st.set_page_config(page_title="Valuation Metrics", layout="wide") | |
| st.title("Valuation Metrics") | |
| st.write( | |
| "This tool tracks and visualizes valuation multiples across time. " | |
| "It includes trailing and forward ratios like P/E, EV/EBITDA, P/S, and others. " | |
| "Data is sourced in real-time from reported financials and analyst forecasts. " | |
| "Use the charts to compare historical trends, assess relative valuation, " | |
| "and flag outliers or shifts in market expectations." | |
| ) | |
| API_KEY = os.getenv("FMP_API_KEY") | |
| # ----- Sidebar (includes Page Selector at the top) ----- | |
| with st.sidebar: | |
| st.title("Parameters") | |
| page = st.radio("Select Metric Page", | |
| ["P/E & PEG", "EV/EBITDA", "EV/EBIT", "P/S Ratio", "P/B Ratio"], | |
| help="Choose the valuation metric page.") | |
| with st.expander("Data Inputs", expanded=True): | |
| ticker = st.text_input("Ticker", "MSFT", help="Enter ticker symbol (e.g. MSFT).") | |
| years_back = st.number_input("Years / Quarters Back", min_value=1, max_value=50, value=10, help="Number of years / quarters of historical data.") | |
| default_start = datetime.date.today() - datetime.timedelta(days=years_back*365) | |
| #start_date = st.date_input("Start Date", default_start, help="Select start date for analysis.") | |
| default_end = datetime.date.today() + datetime.timedelta(days=1) | |
| #end_date = st.date_input("End Date", default_end, help="End date (today +1 day).") | |
| with st.expander("General Settings", expanded=True): | |
| forecast_type = st.selectbox("Forecast Type", ["annual", "quarter"], help="Select forecast frequency.") | |
| run_analysis = st.button("Run Analysis") | |
| # ----- Caching helper (only using spinner, no prints) ----- | |
| def fetch_data(url): | |
| response = requests.get(url) | |
| response.raise_for_status() | |
| return response.json() | |
| # ============================================================================= | |
| # Page 1 – P/E & PEG | |
| # ============================================================================= | |
| def pe_peg_page(): | |
| #st.markdown("---") | |
| st.header("P/E & PEG Ratio") | |
| st.write( | |
| "Displays trailing and forward P/E ratios, plus PEG metrics derived from analyst EPS forecasts. " | |
| "P/E shows how much investors are paying per unit of earnings. " | |
| "PEG adjusts that by expected EPS growth to give a valuation-per-growth view. " | |
| "Use the sidebar to adjust ticker, forecast frequency, and history length. " | |
| "PEG filters control for negative growth and extreme outliers." | |
| ) | |
| st.info( | |
| "Chart legend items can be clicked to toggle series on/off. " | |
| "Hover to inspect exact values. Zoom or pan to focus on specific periods." | |
| ) | |
| with st.expander("Methodology", expanded=False): | |
| st.markdown("#### Methodology: P/E and PEG") | |
| st.markdown("##### 1. Trailing P/E") | |
| st.markdown("Calculated using actual historical earnings.") | |
| st.latex(r"\text{Trailing EPS}_t = \sum_{i=0}^{3} \text{EPS}_{t - i}") | |
| st.latex(r"\text{Trailing P/E}_t = \frac{\text{Stock Price}_t}{\text{Trailing EPS}_t}") | |
| st.markdown("**Notes**") | |
| st.markdown("- High P/E → market pricing in growth or quality.") | |
| st.markdown("- Low P/E → may reflect pessimism or undervaluation.") | |
| st.markdown("- Near-zero or negative EPS inflates or invalidates the ratio.") | |
| st.markdown("---") | |
| st.markdown("##### 2. Forward P/E") | |
| st.markdown("Based on analyst EPS forecasts.") | |
| st.latex(r"\text{Forward EPS}_t^{(X)} = \sum_{i=1}^{4} \text{Forecast EPS}_{t+i}^{(X)}") | |
| st.latex(r"\text{Forward P/E}_t^{(X)} = \frac{\text{Stock Price}_t}{\text{Forward EPS}_t^{(X)}}") | |
| st.markdown("**Notes**") | |
| st.markdown("- Lower forward P/E → priced attractively vs expected earnings.") | |
| st.markdown("- Higher forward P/E → premium pricing or stable outlook.") | |
| st.markdown("- Sensitive to forecast quality.") | |
| st.markdown("---") | |
| st.markdown("##### 3. EPS Growth") | |
| st.markdown("Used to normalize valuation.") | |
| st.latex(r"\text{EPS Growth}_t^{(X)} = \frac{\text{Forward EPS}_t^{(X)}}{\text{Trailing EPS}_t} - 1") | |
| st.latex(r"\text{EPS Growth (Trailing)}_t = \frac{\text{Trailing EPS}_t}{\text{Trailing EPS}_{t - s}} - 1") | |
| st.markdown("\\(s = 4\\) for quarters, \\(s = 1\\) for annual.") | |
| st.markdown("**Warnings**") | |
| st.markdown("- Near-zero EPS inflates growth.") | |
| st.markdown("- Negative trailing EPS makes growth and PEG unusable.") | |
| st.markdown("- Large growth swings distort PEG.") | |
| st.markdown("---") | |
| st.markdown("##### 4. PEG Ratio") | |
| st.markdown("PEG ratio adjusts P/E valuation by growth rate to normalize across companies or periods.") | |
| st.latex(r"\text{PEG}_t^{(X)} = \frac{\text{Forward P/E}_t^{(X)}}{\text{EPS Growth}_t^{(X)} \times 100}") | |
| st.latex(r"\text{Trailing PEG}_t = \frac{\text{Trailing P/E}_t}{\text{EPS Growth (Trailing)}_t \times 100}") | |
| st.markdown("**Interpretation**") | |
| st.markdown("- PEG ≈ 1 → priced in line with growth.") | |
| st.markdown("- PEG < 1 → undervalued vs growth.") | |
| st.markdown("- PEG > 1 → premium pricing.") | |
| st.markdown("**Issues**") | |
| st.markdown("- Near-zero growth → unstable PEG.") | |
| st.markdown("- Negative growth → PEG undefined.") | |
| st.markdown("- Small EPS → unreliable denominator.") | |
| st.markdown("---") | |
| st.markdown("##### 5. Filtering") | |
| st.markdown("- PEG excluded if growth ≤ 0 (unless `INCLUDE_NEGATIVE_PEGS=True`).") | |
| st.markdown("- PEG dropped if \\(|\text{PEG}| > \text{MAX_ABS_PEG}\\).") | |
| st.markdown("- Filters reduce noise and false signals.") | |
| st.markdown("---") | |
| st.markdown("##### 6. How to Read the Outputs") | |
| st.markdown("**Trailing and Forward P/E**") | |
| st.markdown("P/E ratios show how much investors are paying for each unit of earnings.") | |
| st.markdown("- **Trailing P/E** uses actual earnings. Reflects historical profitability.") | |
| st.markdown("- **Forward P/E** uses forecast earnings. Reflects market expectations.") | |
| st.markdown("**How to read the relationship:**") | |
| st.markdown("- **Trailing P/E high, Forward P/E lower** → Analysts expect strong earnings growth. Valuation may look rich today but justified by growth ahead.") | |
| st.markdown("- **Trailing P/E low, Forward P/E even lower** → Possibly undervalued. But check if earnings quality or expectations are weak.") | |
| st.markdown("- **Trailing P/E rising, Forward P/E rising** → Market is pricing in higher growth, but expectations may be getting stretched.") | |
| st.markdown("- **Trailing P/E stable, Forward P/E rising** → Market anticipates improvement, but evidence isn't in earnings yet. This can signal a speculative rebound or recovery play.") | |
| st.markdown("- **Trailing P/E rising, Forward P/E falling** → Analysts expect growth to cool. Watch for slowing fundamentals or sentiment shift.") | |
| st.markdown(" **PEG Ratios**") | |
| st.markdown("**PEG ≈ 1** → Often viewed as fair value for the growth you're buying. Works best when inputs are stable.") | |
| st.markdown("**PEG < 1** → May indicate undervaluation relative to growth. Could be a buying opportunity. But also: could reflect skepticism around forecasts (e.g. biotech, early-stage).") | |
| st.markdown("**PEG > 1** → Paying more than 1x growth rate. Common in stable, brand-heavy, or high-moat businesses. Not always overvalued — could reflect quality, consistency, or low-risk profile.") | |
| # Sidebar parameters | |
| with st.sidebar.expander("P/E & PEG Parameters", expanded=True): | |
| include_negative_pegs = st.checkbox("Include Negative PEGs", value=False, | |
| help="Check to include negative PEGs in the analysis.") | |
| max_abs_peg = st.number_input("Max Absolute PEG", value=10, | |
| help="Filter out PEGs with |PEG| above this value.") | |
| # Initialize session state result if not present | |
| if "pepeg_result" not in st.session_state: | |
| st.session_state.pepeg_result = None | |
| # Run analysis if triggered | |
| if run_analysis: | |
| with st.spinner("Running P/E & PEG analysis..."): | |
| LIMIT = years_back * (4 if forecast_type == "quarter" else 1) | |
| TICKER = ticker.upper() | |
| if forecast_type == "annual": | |
| analyst_period = "annual" | |
| income_url = f"https://financialmodelingprep.com/api/v3/income-statement/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| else: | |
| analyst_period = "quarter" | |
| income_url = f"https://financialmodelingprep.com/api/v3/income-statement/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| analyst_url = f"https://financialmodelingprep.com/api/v3/analyst-estimates/{TICKER}?period={analyst_period}&apikey={API_KEY}" | |
| quote_url = f"https://financialmodelingprep.com/api/v3/quote/{TICKER}?apikey={API_KEY}" | |
| # Helper functions | |
| def local_fetch(url): | |
| return fetch_data(url) | |
| def get_income_data(): | |
| data = local_fetch(income_url) | |
| if not data: | |
| st.error("Income statement data is empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True, ignore_index=True) | |
| if 'eps' not in df.columns: | |
| st.error("Field 'eps' not found in income statement data.") | |
| return None | |
| df['Trailing_EPS'] = df['eps'].rolling(window=4).sum() if forecast_type == "quarter" else df['eps'] | |
| df.dropna(subset=['Trailing_EPS'], inplace=True) | |
| return df | |
| def get_ev_data(): | |
| data = local_fetch(ev_url) | |
| if not data: | |
| st.error("EV data is empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| if 'stockPrice' not in df.columns: | |
| st.error("Field 'stockPrice' missing in EV data.") | |
| return None | |
| return df[['date', 'stockPrice']] | |
| def extend_ev_today(df_ev): | |
| q_data = local_fetch(quote_url) | |
| if q_data: | |
| if 'price' not in q_data[0]: | |
| st.error("Field 'price' missing in quote data.") | |
| else: | |
| current_price = q_data[0]['price'] | |
| today = pd.to_datetime("today").normalize() | |
| df_today = pd.DataFrame({"date": [today], "stockPrice": [current_price]}) | |
| df_ev = pd.concat([df_ev, df_today], ignore_index=True) | |
| df_ev.sort_values("date", inplace=True) | |
| else: | |
| st.warning("Could not fetch today's quote.") | |
| return df_ev | |
| def get_analyst_data(): | |
| data = local_fetch(analyst_url) | |
| if not data: | |
| st.error("Analyst estimates data is empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values("date", inplace=True, ignore_index=True) | |
| for col in ['estimatedEpsLow', 'estimatedEpsAvg', 'estimatedEpsHigh']: | |
| if col not in df.columns: | |
| st.error(f"Field '{col}' not found in analyst data.") | |
| return None | |
| df.rename(columns={ | |
| "estimatedEpsLow": "Forecast_EPS_Low", | |
| "estimatedEpsAvg": "Forecast_EPS_Avg", | |
| "estimatedEpsHigh": "Forecast_EPS_High" | |
| }, inplace=True) | |
| return df | |
| def get_future_eps(date_val, df_analyst): | |
| future = df_analyst[df_analyst["date"] > date_val].sort_values("date") | |
| if forecast_type == "quarter": | |
| future = future.head(4) | |
| low_list = future["Forecast_EPS_Low"].tolist() | |
| avg_list = future["Forecast_EPS_Avg"].tolist() | |
| high_list = future["Forecast_EPS_High"].tolist() | |
| while len(low_list) < 4: low_list.append(np.nan) | |
| while len(avg_list) < 4: avg_list.append(np.nan) | |
| while len(high_list) < 4: high_list.append(np.nan) | |
| return low_list, avg_list, high_list | |
| else: | |
| if len(future) >= 1: | |
| row0 = future.iloc[0] | |
| return ([row0["Forecast_EPS_Low"]] + [np.nan] * 3, | |
| [row0["Forecast_EPS_Avg"]] + [np.nan] * 3, | |
| [row0["Forecast_EPS_High"]] + [np.nan] * 3) | |
| else: | |
| return ([np.nan] * 4, [np.nan] * 4, [np.nan] * 4) | |
| # Data processing | |
| df_income = get_income_data() | |
| df_ev = get_ev_data() | |
| if df_income is None or df_ev is None: | |
| return | |
| df_ev = extend_ev_today(df_ev) | |
| df_trailing = pd.merge( | |
| df_income[["date", "eps", "Trailing_EPS"]], | |
| df_ev[["date", "stockPrice"]], | |
| on="date", how="inner" | |
| ) | |
| df_trailing["Trailing_PE"] = df_trailing["stockPrice"] / df_trailing["Trailing_EPS"] | |
| df_analyst = get_analyst_data() | |
| if df_analyst is None: | |
| return | |
| earliest_date = min(df_income["date"].min(), df_ev["date"].min()) | |
| df_analyst = df_analyst[df_analyst["date"] >= earliest_date].copy() | |
| col_names = ["EPSLow_1", "EPSLow_2", "EPSLow_3", "EPSLow_4", | |
| "EPSAvg_1", "EPSAvg_2", "EPSAvg_3", "EPSAvg_4", | |
| "EPSHigh_1", "EPSHigh_2", "EPSHigh_3", "EPSHigh_4"] | |
| for c in col_names: | |
| df_ev[c] = np.nan | |
| for i in range(len(df_ev)): | |
| d = df_ev.loc[i, "date"] | |
| low_list, avg_list, high_list = get_future_eps(d, df_analyst) | |
| df_ev.at[i, "EPSLow_1"] = low_list[0] | |
| df_ev.at[i, "EPSLow_2"] = low_list[1] | |
| df_ev.at[i, "EPSLow_3"] = low_list[2] | |
| df_ev.at[i, "EPSLow_4"] = low_list[3] | |
| df_ev.at[i, "EPSAvg_1"] = avg_list[0] | |
| df_ev.at[i, "EPSAvg_2"] = avg_list[1] | |
| df_ev.at[i, "EPSAvg_3"] = avg_list[2] | |
| df_ev.at[i, "EPSAvg_4"] = avg_list[3] | |
| df_ev.at[i, "EPSHigh_1"] = high_list[0] | |
| df_ev.at[i, "EPSHigh_2"] = high_list[1] | |
| df_ev.at[i, "EPSHigh_3"] = high_list[2] | |
| df_ev.at[i, "EPSHigh_4"] = high_list[3] | |
| df_ev["ForwardTTM_Low"] = df_ev[["EPSLow_1", "EPSLow_2", "EPSLow_3", "EPSLow_4"]].sum(axis=1, min_count=1) | |
| df_ev["ForwardTTM_Avg"] = df_ev[["EPSAvg_1", "EPSAvg_2", "EPSAvg_3", "EPSAvg_4"]].sum(axis=1, min_count=1) | |
| df_ev["ForwardTTM_High"] = df_ev[["EPSHigh_1", "EPSHigh_2", "EPSHigh_3", "EPSHigh_4"]].sum(axis=1, min_count=1) | |
| df_ev["Forward_PE_Low"] = df_ev.apply(lambda row: row["stockPrice"] / row["ForwardTTM_Low"] | |
| if pd.notna(row["ForwardTTM_Low"]) and row["ForwardTTM_Low"] > 0 else np.nan, axis=1) | |
| df_ev["Forward_PE_Avg"] = df_ev.apply(lambda row: row["stockPrice"] / row["ForwardTTM_Avg"] | |
| if pd.notna(row["ForwardTTM_Avg"]) and row["ForwardTTM_Avg"] > 0 else np.nan, axis=1) | |
| df_ev["Forward_PE_High"] = df_ev.apply(lambda row: row["stockPrice"] / row["ForwardTTM_High"] | |
| if pd.notna(row["ForwardTTM_High"]) and row["ForwardTTM_High"] > 0 else np.nan, axis=1) | |
| df_final = pd.merge(df_trailing, df_ev, on="date", how="outer", suffixes=("_trail", "_fwd")) | |
| df_final.sort_values("date", inplace=True, ignore_index=True) | |
| if "stockPrice_trail" in df_final.columns and "stockPrice_fwd" in df_final.columns: | |
| df_final["stockPrice"] = df_final["stockPrice_trail"].fillna(df_final["stockPrice_fwd"]) | |
| df_final.drop(columns=["stockPrice_trail", "stockPrice_fwd"], inplace=True) | |
| date_set = set(df_income["date"]).union(df_trailing["date"]).union(df_ev["date"]) | |
| df_final = df_final[df_final["date"].isin(date_set)].copy() | |
| df_final["EPSGrowth_Low"] = np.where((df_final["Trailing_EPS"] > 0) & df_final["Trailing_EPS"].notna(), | |
| (df_final["ForwardTTM_Low"] / df_final["Trailing_EPS"]) - 1, np.nan) | |
| df_final["EPSGrowth_Avg"] = np.where((df_final["Trailing_EPS"] > 0) & df_final["Trailing_EPS"].notna(), | |
| (df_final["ForwardTTM_Avg"] / df_final["Trailing_EPS"]) - 1, np.nan) | |
| df_final["EPSGrowth_High"] = np.where((df_final["Trailing_EPS"] > 0) & df_final["Trailing_EPS"].notna(), | |
| (df_final["ForwardTTM_High"] / df_final["Trailing_EPS"]) - 1, np.nan) | |
| if include_negative_pegs: | |
| df_final["PEG_Low"] = df_final["Forward_PE_Low"] / (df_final["EPSGrowth_Low"] * 100) | |
| df_final["PEG_Avg"] = df_final["Forward_PE_Avg"] / (df_final["EPSGrowth_Avg"] * 100) | |
| df_final["PEG_High"] = df_final["Forward_PE_High"] / (df_final["EPSGrowth_High"] * 100) | |
| else: | |
| df_final["PEG_Low"] = np.where(df_final["EPSGrowth_Low"] > 0, | |
| df_final["Forward_PE_Low"] / (df_final["EPSGrowth_Low"] * 100), np.nan) | |
| df_final["PEG_Avg"] = np.where(df_final["EPSGrowth_Avg"] > 0, | |
| df_final["Forward_PE_Avg"] / (df_final["EPSGrowth_Avg"] * 100), np.nan) | |
| df_final["PEG_High"] = np.where(df_final["EPSGrowth_High"] > 0, | |
| df_final["Forward_PE_High"] / (df_final["EPSGrowth_High"] * 100), np.nan) | |
| shift_val = 4 if forecast_type == "quarter" else 1 | |
| df_final["EPSGrowth_Trailing"] = np.where(df_final["Trailing_EPS"].notna() & | |
| (df_final["Trailing_EPS"].shift(shift_val) > 0), | |
| (df_final["Trailing_EPS"] / df_final["Trailing_EPS"].shift(shift_val)) - 1, np.nan) | |
| if include_negative_pegs: | |
| df_final["Trailing_PEG"] = df_final["Trailing_PE"] / (df_final["EPSGrowth_Trailing"] * 100) | |
| else: | |
| df_final["Trailing_PEG"] = np.where(df_final["EPSGrowth_Trailing"] > 0, | |
| df_final["Trailing_PE"] / (df_final["EPSGrowth_Trailing"] * 100), np.nan) | |
| def filter_extreme_peg(x): | |
| if pd.isna(x): | |
| return np.nan | |
| return x if abs(x) <= max_abs_peg else np.nan | |
| df_final["PEG_Low"] = df_final["PEG_Low"].apply(filter_extreme_peg) | |
| df_final["PEG_Avg"] = df_final["PEG_Avg"].apply(filter_extreme_peg) | |
| df_final["PEG_High"] = df_final["PEG_High"].apply(filter_extreme_peg) | |
| df_final["Trailing_PEG"] = df_final["Trailing_PEG"].apply(filter_extreme_peg) | |
| # --- Chart 1: Trailing vs Forward P/E with double y-axes --- | |
| fig1 = go.Figure() | |
| fig1.add_trace(go.Scatter(x=df_final["date"], y=df_final["Trailing_PE"], | |
| mode="lines+markers", name="Trailing P/E", line=dict(width=2), yaxis="y1")) | |
| fig1.add_trace(go.Scatter(x=df_final["date"], y=df_final["Forward_PE_Low"], | |
| mode="lines+markers", name="Forward P/E (Low)", line=dict(width=1), yaxis="y1")) | |
| fig1.add_trace(go.Scatter(x=df_final["date"], y=df_final["Forward_PE_Avg"], | |
| mode="lines+markers", name="Forward P/E (Avg)", line=dict(width=1), yaxis="y1")) | |
| fig1.add_trace(go.Scatter(x=df_final["date"], y=df_final["Forward_PE_High"], | |
| mode="lines+markers", name="Forward P/E (High)", line=dict(width=1), yaxis="y1")) | |
| start_date_str = df_final["date"].min().strftime("%Y-%m-%d") | |
| end_date_str = df_final["date"].max().strftime("%Y-%m-%d") | |
| daily_url = f"https://financialmodelingprep.com/api/v3/historical-price-full/{TICKER}?from={start_date_str}&to={end_date_str}&serietype=line&apikey={API_KEY}" | |
| daily_data = local_fetch(daily_url) | |
| df_daily = pd.DataFrame(daily_data.get("historical", [])) | |
| if not df_daily.empty: | |
| df_daily["date"] = pd.to_datetime(df_daily["date"]) | |
| df_daily.sort_values("date", inplace=True) | |
| fig1.add_trace(go.Scatter(x=df_daily["date"], y=df_daily["close"], | |
| mode="lines", name="Daily Stock Price", line=dict(width=1), opacity=0.2, yaxis="y2")) | |
| if forecast_type == "quarter": | |
| fig1.update_xaxes(tickformat="%Y-%m", dtick="M3") | |
| else: | |
| fig1.update_xaxes(tickformat="%Y", dtick="M12") | |
| fig1.update_layout( | |
| title=f"{TICKER} Trailing vs Forward P/E (Low/Avg/High) with Daily Stock ({forecast_type.capitalize()} freq)", | |
| xaxis=dict(title="Date"), | |
| yaxis=dict(title="P/E Ratio", side="left"), | |
| yaxis2=dict(title="Stock Price", overlaying="y", side="right"), | |
| template="plotly_dark", legend=dict(x=0.02, y=0.98) | |
| ) | |
| # --- Chart 2: PEG Ratios with double y-axes --- | |
| fig2 = go.Figure() | |
| fig2.add_trace(go.Scatter(x=df_final["date"], y=df_final["PEG_Low"], | |
| mode="lines+markers", name="Forward PEG (Low)", line=dict(width=1), yaxis="y1")) | |
| fig2.add_trace(go.Scatter(x=df_final["date"], y=df_final["PEG_Avg"], | |
| mode="lines+markers", name="Forward PEG (Avg)", line=dict(width=1), yaxis="y1")) | |
| fig2.add_trace(go.Scatter(x=df_final["date"], y=df_final["PEG_High"], | |
| mode="lines+markers", name="Forward PEG (High)", line=dict(width=1), yaxis="y1")) | |
| fig2.add_trace(go.Scatter(x=df_final["date"], y=df_final["Trailing_PEG"], | |
| mode="lines+markers", name="Trailing PEG", line=dict(width=1), yaxis="y1")) | |
| if not df_daily.empty: | |
| fig2.add_trace(go.Scatter(x=df_daily["date"], y=df_daily["close"], | |
| mode="lines", name="Daily Stock Price", line=dict(width=1), opacity=0.2, yaxis="y2")) | |
| if forecast_type == "quarter": | |
| fig2.update_xaxes(tickformat="%Y-%m", dtick="M3") | |
| else: | |
| fig2.update_xaxes(tickformat="%Y", dtick="M12") | |
| fig2.update_layout( | |
| title=f"{TICKER} PEG Ratios vs Stock Price ({forecast_type.capitalize()} freq)", | |
| xaxis=dict(title="Date"), | |
| yaxis=dict(title="PEG Ratio", side="left"), | |
| yaxis2=dict(title="Stock Price", overlaying="y", side="right"), | |
| template="plotly_dark", legend=dict(x=0.02, y=0.98) | |
| ) | |
| # Build the dynamic interpretation string (as per your original raw code) | |
| ticker_var = TICKER | |
| period_var = forecast_type.capitalize() | |
| latest_full = df_final.dropna(subset=["Trailing_PE", "Forward_PE_Avg", "PEG_Avg"]).iloc[-1] | |
| latest_date = latest_full["date"].strftime("%Y-%m-%d") | |
| trailing_pe = latest_full["Trailing_PE"] | |
| forward_pe = latest_full["Forward_PE_Avg"] | |
| peg = latest_full["PEG_Avg"] | |
| growth = latest_full["EPSGrowth_Avg"] * 100 | |
| interp_text = f"""--- {ticker_var} Valuation Interpretation ({period_var} data as of {latest_date}) --- | |
| Trailing P/E: {trailing_pe:.2f} | |
| Forward P/E (Avg): {forward_pe:.2f} | |
| EPS Growth (Avg): {growth:.2f}% | |
| Forward PEG (Avg): {peg:.2f} | |
| --- P/E Relationship --- | |
| """ | |
| if forward_pe < trailing_pe and growth > 0: | |
| interp_text += f"Forward P/E is lower than trailing → {ticker_var} is priced for EPS growth under {period_var} expectations.\n" | |
| elif forward_pe > trailing_pe and growth > 0: | |
| interp_text += f"Forward P/E is higher than trailing → {ticker_var} pricing reflects optimism on a future rebound ({period_var} view).\n" | |
| elif forward_pe > trailing_pe and growth <= 0: | |
| interp_text += f"Forward P/E exceeds trailing despite low or negative growth → expectations for {ticker_var} may be decoupled from fundamentals.\n" | |
| else: | |
| interp_text += f"P/E levels are close → {ticker_var} may be priced for flat or stable performance ({period_var} view).\n" | |
| interp_text += "\n--- PEG Ratio Interpretation ---\n" | |
| if np.isnan(peg) or peg > max_abs_peg: | |
| interp_text += f"PEG ratio unavailable or filtered (e.g., due to unstable or extreme inputs in {period_var} data).\n" | |
| elif peg < 0: | |
| interp_text += f"PEG is negative → EPS growth forecast for {ticker_var} is negative in the {period_var} window.\n" | |
| elif peg < 0.8: | |
| interp_text += f"PEG < 0.8 → {ticker_var} is priced lower relative to expected growth in {period_var} forecasts.\n" | |
| elif 0.8 <= peg <= 1.2: | |
| interp_text += f"PEG ≈ 1 → Valuation of {ticker_var} aligns proportionally with its forecast growth ({period_var} data).\n" | |
| elif peg > 1.2 and peg <= 2: | |
| interp_text += f"PEG > 1 → Market may value {ticker_var}'s consistency, margins, or quality beyond pure growth ({period_var} horizon).\n" | |
| else: | |
| interp_text += f"PEG > 2 → Price may reflect attributes outside EPS growth (e.g., defensive profile or brand value).\n" | |
| interp_text += "\n--- Additional practical context ---\n" | |
| if growth < 5: | |
| interp_text += f"EPS growth < 5% → PEG becomes more sensitive to input shifts under {period_var} conditions.\n" | |
| if growth < 0: | |
| interp_text += f"EPS growth is negative → PEG loses interpretability in {period_var} data.\n" | |
| if trailing_pe < 10 and peg < 1: | |
| interp_text += f"Low trailing P/E and PEG → {ticker_var} may be seen as attractively priced relative to growth.\n" | |
| if forward_pe > 25 and peg > 2: | |
| interp_text += f"High forward P/E and PEG → Valuation assumptions for {ticker_var} may be stretched in {period_var} forecast.\n" | |
| if 10 < forward_pe < 20 and peg < 1: | |
| interp_text += f"{ticker_var} has moderate forward P/E and sub-1 PEG → Pricing appears efficient on a growth-adjusted basis.\n" | |
| latest_fwd_row = df_final[df_final["Forward_PE_Avg"].notna()].iloc[-1] | |
| latest_fwd_date = latest_fwd_row["date"].strftime("%Y-%m-%d") | |
| latest_fwd_pe = latest_fwd_row["Forward_PE_Avg"] | |
| if latest_fwd_date != latest_date: | |
| interp_text += f"\nNote: Latest forward P/E ({latest_fwd_pe:.2f}) is from {latest_fwd_date} — a more recent forecast-only update.\n" | |
| if latest_fwd_pe < trailing_pe: | |
| interp_text += f"As of {latest_fwd_date}, {ticker_var} has a forward P/E lower than historical — forward sentiment remains constructive.\n" | |
| elif latest_fwd_pe > trailing_pe: | |
| interp_text += f"As of {latest_fwd_date}, {ticker_var} has a forward P/E above trailing — signals possible rebound expectations.\n" | |
| else: | |
| interp_text += f"As of {latest_fwd_date}, {ticker_var} forward P/E equals trailing — market sees little near-term earnings reversion.\n" | |
| interp_text += f"\n[Summary] {ticker_var} ({period_var}): Trailing P/E = {trailing_pe:.2f}, Forward P/E = {forward_pe:.2f}, EPS Growth = {growth:.2f}%, PEG = {peg:.2f}" | |
| st.session_state.pepeg_result = { | |
| "df_final": df_final, | |
| "fig1": fig1, | |
| "fig2": fig2, | |
| "interpretation": interp_text | |
| } | |
| st.success("P/E & PEG analysis complete.") | |
| # Only display results if the analysis has been run | |
| if st.session_state.pepeg_result is not None: | |
| # Display the two charts | |
| st.plotly_chart(st.session_state.pepeg_result["fig1"], use_container_width=True) | |
| st.plotly_chart(st.session_state.pepeg_result["fig2"], use_container_width=True) | |
| # Single Dynamic Interpretation expander | |
| with st.expander("Dynamic Interpretation", expanded=False): | |
| st.text(st.session_state.pepeg_result["interpretation"]) | |
| # Display final DataFrame | |
| st.dataframe(st.session_state.pepeg_result["df_final"]) | |
| # ============================================================================= | |
| # Page 2 – EV/EBITDA | |
| # ============================================================================= | |
| def ev_ebitda_page(): | |
| #st.markdown("---") | |
| st.header("EV/EBITDA Ratio") | |
| st.write( | |
| "Shows trailing and forward EV/EBITDA based on reported results and analyst EBITDA forecasts. " | |
| "EV/EBITDA measures valuation relative to operating earnings, independent of capital structure. " | |
| "Lower values suggest the stock is priced lower per unit of EBITDA; higher values imply premium pricing." | |
| ) | |
| st.info( | |
| "Chart legend items can be clicked to toggle series on/off. " | |
| "Hover to inspect exact values. Zoom or pan to focus on specific periods." | |
| ) | |
| with st.expander("Methodology", expanded=False): | |
| st.markdown("#### Methodology: EV/EBITDA Ratios") | |
| st.markdown("##### 1. Trailing EV/EBITDA") | |
| st.markdown("Trailing EV/EBITDA is calculated using historical TTM (trailing twelve-month) EBITDA and reported enterprise value:") | |
| st.markdown("###### Formula") | |
| st.latex(r"\text{TTM EBITDA}_t = \sum_{i=0}^{3} \text{EBITDA}_{t - i}") | |
| st.markdown("Then:") | |
| st.latex(r"\text{Trailing EV/EBITDA}_t = \frac{\text{Enterprise Value}_t}{\text{TTM EBITDA}_t}") | |
| st.markdown("###### Interpretation") | |
| st.markdown("- Measures how expensive the company is relative to actual operating earnings.") | |
| st.markdown("- Lower values → potentially cheaper valuation.") | |
| st.markdown("- Higher values → may reflect growth expectations, quality, or overvaluation.") | |
| st.markdown("Unlike P/E, this metric includes debt and ignores non-cash items. It works better across firms with different capital structures.") | |
| st.markdown("---") | |
| st.markdown("##### 2. Forward EV/EBITDA") | |
| st.markdown("Forward EV/EBITDA uses analyst forecasts for EBITDA to project valuation:") | |
| st.markdown("###### Formula") | |
| st.latex(r"\text{Forward EBITDA}^{(X)}_t = \sum_{i=1}^{4} \text{Forecast EBITDA}_{t + i}^{(X)} \quad \text{where } X \in \{\text{Low},\,\text{Avg},\,\text{High}\}") | |
| st.markdown("Then:") | |
| st.latex(r"\text{Forward EV/EBITDA}^{(X)}_t = \frac{\text{Enterprise Value}_t}{\text{Forward EBITDA}^{(X)}_t}") | |
| st.markdown("###### Interpretation") | |
| st.markdown("- Projects how valuation looks against future operating earnings.") | |
| st.markdown("- Lower forward EV/EBITDA may indicate market is undervaluing future EBITDA.") | |
| st.markdown("- Higher values may reflect rich expectations or optimism about margin expansion.") | |
| st.markdown("Forward estimates depend on forecast quality. Optimism or outdated revisions can distort the ratio.") | |
| st.markdown("---") | |
| st.markdown("##### 3. Enterprise Value Context") | |
| st.markdown("EV includes:") | |
| st.latex(r"EV = \text{Market Cap} + \text{Total Debt} - \text{Cash and Equivalents}") | |
| st.markdown("This makes it capital-structure neutral. More stable than market cap alone when companies hold debt or cash.") | |
| st.markdown("---") | |
| st.markdown("##### 4. Summary: How to Use the Outputs") | |
| st.markdown("###### Trailing vs Forward EV/EBITDA") | |
| st.markdown("- **Trailing EV/EBITDA high, forward low** → Market expects earnings to rebound. Could be a turnaround signal or too optimistic.") | |
| st.markdown("- **Trailing low, forward even lower** → Could suggest undervaluation — or deteriorating forecast quality.") | |
| st.markdown("- **Both rising** → Market pricing in growth, but check if EBITDA forecasts are keeping up.") | |
| st.markdown("- **Trailing stable, forward rising** → Margin compression or growth downgrade is expected.") | |
| st.markdown("Always cross-check this against margins, cash flows, and capex to avoid false positives.") | |
| # (No additional sidebar parameters are needed here) | |
| if "ev_ebitda_result" not in st.session_state: | |
| st.session_state.ev_ebitda_result = None | |
| if run_analysis: | |
| with st.spinner("Running EV/EBITDA analysis..."): | |
| LIMIT = years_back * (4 if forecast_type == "quarter" else 1) | |
| TICKER = ticker.upper() | |
| if forecast_type == "annual": | |
| period_str = "annual" | |
| income_url = f"https://financialmodelingprep.com/api/v3/income-statement/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| else: | |
| period_str = "quarter" | |
| income_url = f"https://financialmodelingprep.com/api/v3/income-statement/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| analyst_url = f"https://financialmodelingprep.com/api/v3/analyst-estimates/{TICKER}?period={period_str}&apikey={API_KEY}" | |
| quote_url = f"https://financialmodelingprep.com/api/v3/quote/{TICKER}?apikey={API_KEY}" | |
| def local_fetch(url): | |
| return fetch_data(url) | |
| def get_income_data(): | |
| data = local_fetch(income_url) | |
| if not data: | |
| st.error("Income statement data is empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| if 'ebitda' not in df.columns: | |
| st.error("Field 'ebitda' not found in income statement.") | |
| return None | |
| df.rename(columns={'ebitda': 'EBITDA_raw'}, inplace=True) | |
| df['TTM_EBITDA'] = df['EBITDA_raw'].rolling(4).sum() if forecast_type == "quarter" else df['EBITDA_raw'] | |
| df.dropna(subset=['TTM_EBITDA'], inplace=True) | |
| return df | |
| def get_ev_data(): | |
| data = local_fetch(ev_url) | |
| if not data: | |
| st.error("EV data is empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| if 'enterpriseValue' not in df.columns: | |
| st.error("Field 'enterpriseValue' missing in EV data.") | |
| return None | |
| return df[['date', 'enterpriseValue']] | |
| def extend_ev_today(df_ev): | |
| qdata = local_fetch(quote_url) | |
| if qdata: | |
| today_value = qdata[0].get('enterpriseValue', None) | |
| today = pd.to_datetime('today').normalize() | |
| df_today = pd.DataFrame({'date': [today], 'enterpriseValue': [today_value]}) | |
| else: | |
| df_today = pd.DataFrame({'date': [pd.to_datetime('today').normalize()], 'enterpriseValue': [None]}) | |
| df_ev = pd.concat([df_ev, df_today], ignore_index=True).sort_values('date') | |
| return df_ev | |
| def get_analyst_data(): | |
| data = local_fetch(analyst_url) | |
| if not data: | |
| st.error("Analyst estimates data is empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| for col in ['estimatedEbitdaLow', 'estimatedEbitdaAvg', 'estimatedEbitdaHigh']: | |
| if col not in df.columns: | |
| st.error(f"Field '{col}' missing in analyst data.") | |
| return None | |
| df.rename(columns={'estimatedEbitdaLow': 'Forecast_EBITDA_Low', | |
| 'estimatedEbitdaAvg': 'Forecast_EBITDA_Avg', | |
| 'estimatedEbitdaHigh': 'Forecast_EBITDA_High'}, inplace=True) | |
| return df | |
| def forecast_ebitda_rows(d, df_analyst): | |
| future = df_analyst[df_analyst['date'] > d].sort_values('date') | |
| if forecast_type == "quarter": | |
| future = future.head(4) | |
| else: | |
| future = future.head(1) | |
| if future.empty: | |
| return [], [], [] | |
| lows = future['Forecast_EBITDA_Low'].tolist() | |
| avgs = future['Forecast_EBITDA_Avg'].tolist() | |
| highs = future['Forecast_EBITDA_High'].tolist() | |
| while len(lows) < 4: lows.append(np.nan) | |
| while len(avgs) < 4: avgs.append(np.nan) | |
| while len(highs) < 4: highs.append(np.nan) | |
| return lows, avgs, highs | |
| df_income = get_income_data() | |
| df_ev = get_ev_data() | |
| if df_income is None or df_ev is None: | |
| return | |
| df_ev = extend_ev_today(df_ev) | |
| df_trailing = pd.merge(df_income[['date', 'EBITDA_raw', 'TTM_EBITDA']], | |
| df_ev, on='date', how='outer') | |
| df_trailing['Trailing_EV_EBITDA'] = df_trailing.apply( | |
| lambda row: row['enterpriseValue'] / row['TTM_EBITDA'] if pd.notna(row['enterpriseValue']) and row['TTM_EBITDA'] != 0 else np.nan, | |
| axis=1 | |
| ) | |
| df_analyst = get_analyst_data() | |
| if df_analyst is None: | |
| return | |
| # Add forecast EBITDA columns into df_ev | |
| for c in ['EBITDALow_1','EBITDALow_2','EBITDALow_3','EBITDALow_4', | |
| 'EBITDAAvg_1','EBITDAAvg_2','EBITDAAvg_3','EBITDAAvg_4', | |
| 'EBITDAHigh_1','EBITDAHigh_2','EBITDAHigh_3','EBITDAHigh_4']: | |
| df_ev[c] = np.nan | |
| for i in range(len(df_ev)): | |
| d = df_ev.loc[i, 'date'] | |
| lows, avgs, highs = forecast_ebitda_rows(d, df_analyst) | |
| df_ev.at[i, 'EBITDALow_1'] = lows[0] | |
| df_ev.at[i, 'EBITDALow_2'] = lows[1] | |
| df_ev.at[i, 'EBITDALow_3'] = lows[2] | |
| df_ev.at[i, 'EBITDALow_4'] = lows[3] | |
| df_ev.at[i, 'EBITDAAvg_1'] = avgs[0] | |
| df_ev.at[i, 'EBITDAAvg_2'] = avgs[1] | |
| df_ev.at[i, 'EBITDAAvg_3'] = avgs[2] | |
| df_ev.at[i, 'EBITDAAvg_4'] = avgs[3] | |
| df_ev.at[i, 'EBITDAHigh_1'] = highs[0] | |
| df_ev.at[i, 'EBITDAHigh_2'] = highs[1] | |
| df_ev.at[i, 'EBITDAHigh_3'] = highs[2] | |
| df_ev.at[i, 'EBITDAHigh_4'] = highs[3] | |
| df_ev['ForwardTTM_Low'] = df_ev[['EBITDALow_1','EBITDALow_2','EBITDALow_3','EBITDALow_4']].sum(axis=1, min_count=1) | |
| df_ev['ForwardTTM_Avg'] = df_ev[['EBITDAAvg_1','EBITDAAvg_2','EBITDAAvg_3','EBITDAAvg_4']].sum(axis=1, min_count=1) | |
| df_ev['ForwardTTM_High'] = df_ev[['EBITDAHigh_1','EBITDAHigh_2','EBITDAHigh_3','EBITDAHigh_4']].sum(axis=1, min_count=1) | |
| df_ev['Forward_EV_EBITDA_Low'] = df_ev.apply(lambda row: row['enterpriseValue'] / row['ForwardTTM_Low'] | |
| if pd.notna(row['enterpriseValue']) and pd.notna(row['ForwardTTM_Low']) and row['ForwardTTM_Low'] != 0 else np.nan, | |
| axis=1) | |
| df_ev['Forward_EV_EBITDA_Avg'] = df_ev.apply(lambda row: row['enterpriseValue'] / row['ForwardTTM_Avg'] | |
| if pd.notna(row['enterpriseValue']) and pd.notna(row['ForwardTTM_Avg']) and row['ForwardTTM_Avg'] != 0 else np.nan, | |
| axis=1) | |
| df_ev['Forward_EV_EBITDA_High'] = df_ev.apply(lambda row: row['enterpriseValue'] / row['ForwardTTM_High'] | |
| if pd.notna(row['enterpriseValue']) and pd.notna(row['ForwardTTM_High']) and row['ForwardTTM_High'] != 0 else np.nan, | |
| axis=1) | |
| df_final = pd.merge(df_trailing, df_ev, on='date', how='outer', suffixes=('_trailing','_fwd')) | |
| df_final.sort_values('date', inplace=True) | |
| if 'enterpriseValue_trailing' in df_final.columns and 'enterpriseValue_fwd' in df_final.columns: | |
| df_final['enterpriseValue'] = df_final['enterpriseValue_trailing'].fillna(df_final['enterpriseValue_fwd']) | |
| df_final.drop(columns=['enterpriseValue_trailing','enterpriseValue_fwd'], inplace=True) | |
| date_set = set(df_income['date']) | set(df_trailing['date']) | set(df_ev['date']) | |
| df_final = df_final[df_final['date'].isin(date_set)].copy() | |
| start_date_str = df_final['date'].min().strftime('%Y-%m-%d') | |
| end_date_str = df_final['date'].max().strftime('%Y-%m-%d') | |
| daily_url = f"https://financialmodelingprep.com/api/v3/historical-price-full/{TICKER}?from={start_date_str}&to={end_date_str}&serietype=line&apikey={API_KEY}" | |
| daily_data = local_fetch(daily_url) | |
| df_daily = pd.DataFrame(daily_data.get('historical', [])) | |
| if not df_daily.empty: | |
| df_daily['date'] = pd.to_datetime(df_daily['date']) | |
| df_daily.sort_values('date', inplace=True) | |
| # Build chart using double y-axes | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Trailing_EV_EBITDA'], | |
| mode='lines+markers', name='Trailing EV/EBITDA', line=dict(width=2), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_EV_EBITDA_Low'], | |
| mode='lines+markers', name='Forward EV/EBITDA (Low)', line=dict(width=1), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_EV_EBITDA_Avg'], | |
| mode='lines+markers', name='Forward EV/EBITDA (Avg)', line=dict(width=1), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_EV_EBITDA_High'], | |
| mode='lines+markers', name='Forward EV/EBITDA (High)', line=dict(width=1), yaxis="y1")) | |
| if not df_daily.empty: | |
| fig.add_trace(go.Scatter(x=df_daily['date'], y=df_daily['close'], | |
| mode='lines', name='Daily Stock Price', line=dict(width=1), opacity=0.2, yaxis="y2")) | |
| if forecast_type == "quarter": | |
| fig.update_xaxes(tickformat="%Y-%m", dtick="M3") | |
| else: | |
| fig.update_xaxes(tickformat="%Y", dtick="M12") | |
| fig.update_layout( | |
| title=f"{TICKER} EV/EBITDA (Trailing & Forward Low/Avg/High) with Daily Stock ({forecast_type.capitalize()} freq)", | |
| xaxis=dict(title='Date'), | |
| yaxis=dict(title='EV/EBITDA Ratio', side="left"), | |
| yaxis2=dict(title='Stock Price', overlaying="y", side="right"), | |
| template='plotly_dark', legend=dict(x=0.02, y=0.98) | |
| ) | |
| # Build dynamic interpretation string using the raw interpretation block provided | |
| # (Assumes df_final has at least one valid row for Forward_EV_EBITDA_Avg) | |
| latest_row = df_final[df_final[['Trailing_EV_EBITDA', 'Forward_EV_EBITDA_Avg']].notna().all(axis=1)].iloc[-1] | |
| latest_date = latest_row['date'].strftime('%Y-%m-%d') | |
| trailing_val = latest_row['Trailing_EV_EBITDA'] | |
| forward_avg = latest_row['Forward_EV_EBITDA_Avg'] | |
| forward_low = latest_row['Forward_EV_EBITDA_Low'] | |
| forward_high = latest_row['Forward_EV_EBITDA_High'] | |
| interp_text = f"""--- {TICKER} EV/EBITDA Interpretation ({period_str.capitalize()} data as of {latest_date}) --- | |
| Trailing EV/EBITDA: {trailing_val:.2f} | |
| Forward EV/EBITDA (Avg): {forward_avg:.2f} | |
| Forward EV/EBITDA Range: [{forward_low:.2f}, {forward_high:.2f}] | |
| -- RELATIVE LEVEL: Forward vs Trailing -- | |
| """ | |
| if forward_avg < trailing_val: | |
| interp_text += f"Forward EV/EBITDA is lower than trailing → the market may be anticipating higher EBITDA in future {forecast_type.lower()}s.\n" | |
| interp_text += "This dynamic can reflect confidence in operational leverage or growth in contribution margin.\n" | |
| interp_text += "Lower forward multiples in this case suggest valuation compresses if EBITDA targets are hit.\n" | |
| elif forward_avg > trailing_val: | |
| interp_text += f"Forward EV/EBITDA is higher than trailing → future EBITDA is expected to be flat or weaker relative to today.\n" | |
| interp_text += "This can imply valuation uplift is not supported by near-term EBITDA growth.\n" | |
| interp_text += "Investors could be paying more today based on optionality, strategic value, or stability expectations.\n" | |
| else: | |
| interp_text += f"Forward and trailing EV/EBITDA are roughly equal → no material change expected in operating performance.\n" | |
| interp_text += "This tends to show a steady-state assumption by the market.\n" | |
| interp_text += "\n-- ABSOLUTE LEVEL: Forward EV/EBITDA (Valuation framing) --\n" | |
| if forward_avg < 8: | |
| interp_text += f"Forward EV/EBITDA < 8 → {TICKER} appears inexpensive on forward {forecast_type.lower()} performance.\n" | |
| interp_text += "At these levels, the valuation multiple is often associated with value segments, cyclicals, or uncertainty.\n" | |
| elif 8 <= forward_avg <= 14: | |
| interp_text += f"Forward EV/EBITDA in 8–14 range → common for mature operators with predictable margin structures.\n" | |
| interp_text += "This range typically reflects healthy but not speculative expectations.\n" | |
| else: | |
| interp_text += f"Forward EV/EBITDA > 14 → valuation implies elevated expectations.\n" | |
| interp_text += "Market may be assigning a premium for stability, brand strength, network effects, or strategic factors.\n" | |
| interp_text += "Alternatively, high EV/EBITDA with soft forecasts can point to stretched pricing.\n" | |
| interp_text += "\n-- DISPERSION: Forecast Range --\n" | |
| if pd.notna(forward_low) and pd.notna(forward_high): | |
| spread = forward_high - forward_low | |
| if spread > 5: | |
| interp_text += f"Forecast range is wide ({spread:.2f} multiple points) → dispersion in EBITDA outlook is elevated.\n" | |
| interp_text += "This can come from disagreement in assumptions around margin normalization, revenue trajectory, or cost inflation.\n" | |
| interp_text += "High dispersion tends to reduce confidence in the forward signal and makes the valuation more sensitive to sentiment.\n" | |
| elif spread < 2: | |
| interp_text += f"Forecast range is tight ({spread:.2f} points) → analysts generally agree on expected operating results.\n" | |
| interp_text += "Lower uncertainty in forecasts may support cleaner valuation signals and tighter trading multiples.\n" | |
| interp_text += "\n-- EDGE CASE: Forward-only data --\n" | |
| forward_only_row = df_final[df_final['Forward_EV_EBITDA_Avg'].notna()].iloc[-1] | |
| forward_only_date = forward_only_row['date'].strftime('%Y-%m-%d') | |
| forward_only_val = forward_only_row['Forward_EV_EBITDA_Avg'] | |
| if forward_only_date != latest_date: | |
| interp_text += f"Note: Most recent forward-only EV/EBITDA observation is from {forward_only_date}, value = {forward_only_val:.2f}\n" | |
| if forward_only_val < trailing_val: | |
| interp_text += "This still reflects a discount relative to trailing EBITDA multiple, assuming EBITDA growth materializes.\n" | |
| elif forward_only_val > trailing_val: | |
| interp_text += "Forward-only multiple is elevated → could indicate lower expected EBITDA for the next forecast period.\n" | |
| interp_text += "Might also be due to a higher EV figure if the price moved ahead of earnings revisions.\n" | |
| interp_text += f"\n[Summary] {TICKER} ({period_str.capitalize()}): Trailing EV/EBITDA = {trailing_val:.2f}, Forward EV/EBITDA (Avg) = {forward_avg:.2f}, Range = [{forward_low:.2f}, {forward_high:.2f}]" | |
| st.session_state.ev_ebitda_result = { | |
| "df_final": df_final, | |
| "fig": fig, | |
| "interpretation": interp_text | |
| } | |
| st.success("EV/EBITDA analysis complete.") | |
| if st.session_state.ev_ebitda_result is not None: | |
| # Display the chart | |
| st.plotly_chart(st.session_state.ev_ebitda_result["fig"], use_container_width=True) | |
| # Single Dynamic Interpretation expander | |
| with st.expander("Dynamic Interpretation", expanded=False): | |
| st.text(st.session_state.ev_ebitda_result["interpretation"]) | |
| # Display final DataFrame | |
| st.dataframe(st.session_state.ev_ebitda_result["df_final"]) | |
| # ============================================================================= | |
| # Page 3 – P/B Ratio | |
| # ============================================================================= | |
| def pb_ratio_page(): | |
| #st.markdown("---") | |
| st.header("P/B Ratio") | |
| st.write( | |
| "This page computes the Price-to-Book (P/B) Ratio and Book Value per Share (BVPS). " | |
| "Use it to assess valuation versus the balance sheet. " | |
| "Best combined with profitability metrics like ROE or net margins for context." | |
| ) | |
| st.info( | |
| "Chart legend items can be clicked to toggle series on/off. " | |
| "Hover to inspect exact values. Zoom or pan to focus on specific periods." | |
| ) | |
| # Methodology expander | |
| with st.expander("Methodology", expanded=False): | |
| st.markdown("#### Methodology: Price-to-Book (P/B) Ratio") | |
| st.markdown("This chart tracks valuation trends versus underlying book value over time.") | |
| st.markdown("##### 1. Book Value Per Share (BVPS)") | |
| st.markdown("Book value per share is computed using total equity and shares outstanding.") | |
| st.markdown("###### Formula") | |
| st.latex(r"\text{BVPS}_t = \frac{\text{Total Equity}_t}{\text{Number of Shares}_t}") | |
| st.markdown("- Total equity is sourced from the latest balance sheet as of each date.") | |
| st.markdown("- Number of shares is aligned to the same date (or as-of matched).") | |
| st.markdown("###### Interpretation") | |
| st.markdown("- Measures the per-share value of net assets.") | |
| st.markdown("- Rising BVPS → equity base is growing.") | |
| st.markdown("- Flat or declining BVPS → dilution, losses, or stagnant balance sheet.") | |
| st.markdown("---") | |
| st.markdown("##### 2. Price-to-Book Ratio (P/B)") | |
| st.markdown("The P/B ratio is calculated as:") | |
| st.latex(r"\text{P/B Ratio}_t = \frac{\text{Stock Price}_t}{\text{BVPS}_t}") | |
| st.markdown("###### Interpretation") | |
| st.markdown("- P/B < 1 → stock trades below net asset value. Could imply undervaluation or distress.") | |
| st.markdown("- P/B ≈ 1 → market is valuing the business near its net asset base.") | |
| st.markdown("- P/B > 1 → market sees value beyond assets (e.g. brand, IP, growth).") | |
| st.markdown("---") | |
| st.markdown("##### 3. Relationship Between Inputs") | |
| st.markdown("- **Rising price, flat BVPS** → P/B increases. Market is bidding the stock up without balance sheet growth. May signal rerating or momentum.") | |
| st.markdown("- **Flat price, rising BVPS** → P/B decreases. Business value is compounding, but price isn't reflecting it yet.") | |
| st.markdown("- **Both rising proportionally** → P/B stays stable. Valuation keeps pace with book value growth.") | |
| st.markdown("- **BVPS growing faster than price** → P/B compresses. Could indicate improving fundamentals not yet priced in.") | |
| st.markdown("- **Price rising faster than BVPS** → P/B expands. May reflect sentiment shift or expectations of better returns on equity.") | |
| st.markdown("---") | |
| st.markdown("##### 4. Practical Flags") | |
| st.markdown("- **BVPS near zero or negative** → P/B ratio becomes meaningless. Avoid interpreting in these cases.") | |
| st.markdown("- **Large jumps in equity or share count** → Check for corporate actions like buybacks, dilution, capital raises, or restatements.") | |
| st.markdown("- **Stock price spike with flat BVPS** → P/B expands due to sentiment or speculative moves. Validate with fundamentals.") | |
| st.markdown("- **Price drop with flat BVPS** → P/B compresses. Market is de-rating the stock despite unchanged book value.") | |
| st.markdown("- **Sudden P/B swings** → Can signal data issues, corporate events, or anomalies in equity reporting.") | |
| st.markdown("---") | |
| st.markdown("##### 5. Use Cases") | |
| st.markdown("- Most relevant in financials, cyclicals, or capital-intensive firms.") | |
| st.markdown("- Less useful for asset-light sectors (e.g. software, media).") | |
| st.markdown("- Combine with ROE and margin metrics to assess valuation versus quality.") | |
| # (No extra sidebar expander for parameters is needed.) | |
| if "pb_result" not in st.session_state: | |
| st.session_state.pb_result = None | |
| if run_analysis: | |
| with st.spinner("Running P/B Ratio analysis..."): | |
| LIMIT = years_back * (4 if forecast_type == "quarter" else 1) | |
| TICKER = ticker.upper() | |
| if forecast_type == "annual": | |
| bs_url = f"https://financialmodelingprep.com/api/v3/balance-sheet-statement/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| else: | |
| bs_url = f"https://financialmodelingprep.com/api/v3/balance-sheet-statement/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| quote_url = f"https://financialmodelingprep.com/api/v3/quote/{TICKER}?apikey={API_KEY}" | |
| def local_fetch(url): | |
| return fetch_data(url) | |
| def get_balance_sheet(): | |
| data = local_fetch(bs_url) | |
| if not data: | |
| st.error("Balance sheet data empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True, ignore_index=True) | |
| eq_field = 'totalStockholdersEquity' if 'totalStockholdersEquity' in df.columns else ( | |
| 'totalEquity' if 'totalEquity' in df.columns else None) | |
| if not eq_field: | |
| st.error("No equity field found in balance sheet data.") | |
| return None | |
| df.rename(columns={eq_field: 'Total_Equity'}, inplace=True) | |
| return df | |
| def get_ev_data(): | |
| data = local_fetch(ev_url) | |
| if not data: | |
| st.error("EV data empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| for col in ['stockPrice', 'numberOfShares']: | |
| if col not in df.columns: | |
| st.error(f"No '{col}' in EV data.") | |
| return None | |
| return df[['date','stockPrice','numberOfShares']] | |
| def extend_ev_today(df_ev): | |
| q_data = local_fetch(quote_url) | |
| if q_data: | |
| daily_price = q_data[0].get('price', None) | |
| shares = q_data[0].get('sharesOutstanding', q_data[0].get('numberOfShares', None)) | |
| today = pd.to_datetime('today').normalize() | |
| df_today = pd.DataFrame({'date': [today], 'stockPrice': [daily_price], 'numberOfShares': [shares]}) | |
| else: | |
| df_today = pd.DataFrame({'date': [pd.to_datetime('today').normalize()], | |
| 'stockPrice': [None], | |
| 'numberOfShares': [None]}) | |
| df_ev = pd.concat([df_ev, df_today], ignore_index=True).sort_values('date') | |
| return df_ev | |
| def merge_equity_ev(df_bs, df_ev): | |
| df_bs_sorted = df_bs.sort_values('date') | |
| df_ev_sorted = df_ev.sort_values('date') | |
| df_merged = pd.merge_asof(df_ev_sorted, df_bs_sorted[['date', 'Total_Equity']], on='date', direction='backward') | |
| return df_merged | |
| df_bs = get_balance_sheet() | |
| df_ev = get_ev_data() | |
| if df_bs is None or df_ev is None: | |
| return | |
| df_ev = extend_ev_today(df_ev) | |
| df_merged = merge_equity_ev(df_bs, df_ev) | |
| df_merged['Book_Value_Per_Share'] = df_merged['Total_Equity'] / df_merged['numberOfShares'] | |
| df_merged['PB_Ratio'] = df_merged['stockPrice'] / df_merged['Book_Value_Per_Share'] | |
| date_set = set(df_bs['date']) | set(df_ev['date']) | |
| df_final = df_merged[df_merged['date'].isin(date_set)].copy() | |
| start_date_str = df_final['date'].min().strftime('%Y-%m-%d') | |
| end_date_str = df_final['date'].max().strftime('%Y-%m-%d') | |
| daily_url = f"https://financialmodelingprep.com/api/v3/historical-price-full/{TICKER}?from={start_date_str}&to={end_date_str}&serietype=line&apikey={API_KEY}" | |
| daily_data = local_fetch(daily_url) | |
| df_daily = pd.DataFrame(daily_data.get('historical', [])) | |
| if not df_daily.empty: | |
| df_daily['date'] = pd.to_datetime(df_daily['date']) | |
| df_daily.sort_values('date', inplace=True) | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['PB_Ratio'], | |
| mode='lines+markers', name='P/B Ratio', line=dict(width=2), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Book_Value_Per_Share'], | |
| mode='lines+markers', name='Book Value Per Share', line=dict(width=1), opacity=0.3, yaxis="y2")) | |
| if not df_daily.empty: | |
| fig.add_trace(go.Scatter(x=df_daily['date'], y=df_daily['close'], | |
| mode='lines', name='Daily Stock Price', line=dict(width=1), opacity=0.2, yaxis="y2")) | |
| else: | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['stockPrice'], | |
| mode='lines', name=f"{forecast_type.capitalize()} Stock Price", line=dict(width=1), opacity=0.3, yaxis="y2")) | |
| if forecast_type=="quarter": | |
| fig.update_xaxes(tickformat="%Y-%m", dtick="M3") | |
| else: | |
| fig.update_xaxes(tickformat="%Y", dtick="M12") | |
| fig.update_layout( | |
| title=f"{TICKER} Price-to-Book (P/B) Ratio with Book Value/Share ({forecast_type.capitalize()} data)", | |
| xaxis=dict(title="Date"), | |
| yaxis=dict(title="P/B Ratio", side="left"), | |
| yaxis2=dict(title="Book Value/Share & Stock Price", overlaying="y", side="right"), | |
| template="plotly_dark", legend=dict(x=0.02, y=0.98) | |
| ) | |
| interpretation = f"""--- {TICKER} Price-to-Book Analysis ({forecast_type.capitalize()} data as of {df_final['date'].iloc[-1].strftime('%Y-%m-%d')}) --- | |
| P/B Ratio: {df_final['PB_Ratio'].iloc[-1]:.2f} | |
| Book Value per Share: {df_final['Book_Value_Per_Share'].iloc[-1]:,.2f} | |
| Stock Price: {df_final['stockPrice'].iloc[-1]:,.2f} | |
| --- Absolute level analysis --- | |
| {"P/B < 1 → " + TICKER + " is trading below book value." if df_final['PB_Ratio'].iloc[-1] < 1 else "P/B between 1–2 → " + TICKER + " is priced modestly above book value." if 1 <= df_final['PB_Ratio'].iloc[-1] <= 2 else "P/B > 2 → " + TICKER + " trades well above its net asset value."} | |
| --- Temporal pattern analysis --- | |
| {"P/B has increased recently." if (df_final['PB_Ratio'].tail(4).iloc[-1] - df_final['PB_Ratio'].tail(4).iloc[0]) > 0.2 else "P/B has declined recently." if (df_final['PB_Ratio'].tail(4).iloc[-1] - df_final['PB_Ratio'].tail(4).iloc[0]) < -0.2 else "P/B has remained relatively stable."} | |
| [Summary] {TICKER} ({forecast_type.capitalize()}): Stock trades at {df_final['PB_Ratio'].iloc[-1]:.2f}x book value. | |
| """ | |
| st.session_state.pb_result = {"df_final": df_final, "fig": fig, "interpretation": interpretation} | |
| st.success("P/B Ratio analysis complete.") | |
| if st.session_state.pb_result is not None: | |
| st.plotly_chart(st.session_state.pb_result["fig"], use_container_width=True) | |
| # Single Dynamic Interpretation expander | |
| with st.expander("Dynamic Interpretation", expanded=False): | |
| st.text(st.session_state.pb_result["interpretation"]) | |
| # Display final DataFrame and chart | |
| st.dataframe(st.session_state.pb_result["df_final"], use_container_width=True) | |
| # ============================================================================= | |
| # Page 4 – P/S Ratio | |
| # ============================================================================= | |
| def ps_ratio_page(): | |
| #st.markdown("---") | |
| st.header("P/S Ratio") | |
| st.write( | |
| "This page calculates trailing and forward Price-to-Sales (P/S) ratios. " | |
| "Use it to compare valuation against actual and forecast revenue levels. " | |
| "Especially useful when earnings are distorted or unavailable." | |
| ) | |
| st.info( | |
| "Chart legend items can be clicked to toggle series on/off. " | |
| "Hover to inspect exact values. Zoom or pan to focus on specific periods." | |
| ) | |
| with st.expander("Methodology", expanded=False): | |
| st.markdown("#### Methodology: Price-to-Sales (P/S) Ratio") | |
| st.markdown("This chart visualizes market valuation relative to top-line performance over time.") | |
| st.markdown("##### 1. Trailing Revenue and Market Cap") | |
| st.markdown("Revenue is taken as either annual (if `FORECAST_TYPE = 'annual'`) or as trailing four quarters (TTM) for quarterly data.") | |
| st.markdown("###### Formula") | |
| st.latex(r"TTM\,Revenue_t = \sum_{i=0}^{3} Revenue_{t-i}") | |
| st.markdown("Market cap is computed as:") | |
| st.latex(r"Market\,Cap_t = Stock\,Price_t \times Shares\,Outstanding_t") | |
| st.markdown("---") | |
| st.markdown("##### 2. Trailing P/S Ratio") | |
| st.markdown("###### Formula") | |
| st.latex(r"Trailing\,P/S_t = \frac{Market\,Cap_t}{TTM\,Revenue_t}") | |
| st.markdown("###### Interpretation") | |
| st.markdown("- Shows how much the market is paying per unit of revenue.") | |
| st.markdown("- Higher P/S → pricing in strong growth, margins, or defensibility.") | |
| st.markdown("- Lower P/S → cheaper relative to revenue, could reflect uncertainty or weaker outlook.") | |
| st.markdown("---") | |
| st.markdown("##### 3. Forward P/S Ratio") | |
| st.markdown("Forecasted revenues (low, average, high) are summed over the next 4 quarters:") | |
| st.latex(r"Forward\,Revenue_t^{(X)} = \sum_{i=1}^{4} Forecast\,Revenue_{t+i}^{(X)}") | |
| st.markdown("Then:") | |
| st.latex(r"Forward\,P/S_t^{(X)} = \frac{Market\,Cap_t}{Forward\,Revenue_t^{(X)}}") | |
| st.markdown("###### Interpretation") | |
| st.markdown("- Lower forward P/S → lower valuation against expected revenue.") | |
| st.markdown("- Higher forward P/S → may reflect baked-in optimism or strong sentiment.") | |
| st.markdown("---") | |
| st.markdown("##### 4. Practical Interpretation") | |
| st.markdown("- **Stock price rising, revenue flat** → P/S increases. This may reflect bullish sentiment or rerating, even without business growth.") | |
| st.markdown("- **Revenue growing, price flat** → P/S compresses. Valuation gets cheaper. Could be overlooked improvement or lagging market recognition.") | |
| st.markdown("- **Both price and revenue rising** → P/S holds steady. Implies market is rewarding growth proportionally.") | |
| st.markdown("- **P/S rising while revenue is flat or falling** → Suggests a speculative move. Check if it's based on expectations or hype.") | |
| st.markdown("- **P/S falling while revenue is stable or rising** → Could point to derating, skepticism, or risk-off sentiment.") | |
| st.markdown("- **Volatile P/S with stable inputs** → Look for restatements, share count errors, or stale data.") | |
| st.markdown("---") | |
| st.markdown("##### 5. Use Cases and Caveats") | |
| st.markdown("- More stable than P/E for early-stage or low-margin firms.") | |
| st.markdown("- Useful in SaaS, recurring-revenue, and growth sectors.") | |
| st.markdown("- On its own, does not reflect margins, profitability, or capital efficiency.") | |
| st.markdown("###### Key Flags") | |
| st.markdown("- High P/S with weak margins → could signal overvaluation.") | |
| st.markdown("- Low P/S in recurring-revenue models → may point to undervaluation.") | |
| st.markdown("- Sharp drops in revenue forecasts → spikes in forward P/S.") | |
| st.markdown("- Near-zero forecast revenue → forward P/S becomes unreliable.") | |
| # No additional sidebar parameters are used for this page. | |
| if "ps_result" not in st.session_state: | |
| st.session_state.ps_result = None | |
| if run_analysis: | |
| with st.spinner("Running P/S Ratio analysis..."): | |
| LIMIT = years_back * (4 if forecast_type == "quarter" else 1) | |
| TICKER = ticker.upper() | |
| if forecast_type == "annual": | |
| period_str = "annual" | |
| income_url = f"https://financialmodelingprep.com/api/v3/income-statement/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| else: | |
| period_str = "quarter" | |
| income_url = f"https://financialmodelingprep.com/api/v3/income-statement/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| analyst_url = f"https://financialmodelingprep.com/api/v3/analyst-estimates/{TICKER}?period={period_str}&apikey={API_KEY}" | |
| quote_url = f"https://financialmodelingprep.com/api/v3/quote/{TICKER}?apikey={API_KEY}" | |
| def local_fetch(url): | |
| return fetch_data(url) | |
| def get_income_data(): | |
| data = local_fetch(income_url) | |
| if not data: | |
| st.error("Income statement data empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| if 'revenue' not in df.columns: | |
| st.error("Revenue field missing in income statement data.") | |
| return None | |
| df.rename(columns={'revenue': 'Revenue_raw'}, inplace=True) | |
| df['TTM_Revenue'] = df['Revenue_raw'].rolling(4).sum() if forecast_type == "quarter" else df['Revenue_raw'] | |
| df.dropna(subset=['TTM_Revenue'], inplace=True) | |
| return df | |
| def get_ev_data(): | |
| data = local_fetch(ev_url) | |
| if not data: | |
| st.error("EV data empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| for col in ['stockPrice', 'numberOfShares']: | |
| if col not in df.columns: | |
| st.error(f"Field '{col}' missing in EV data.") | |
| return None | |
| return df[['date', 'stockPrice', 'numberOfShares']] | |
| def extend_ev_today(df_ev): | |
| qdata = local_fetch(quote_url) | |
| if qdata: | |
| if 'price' not in qdata[0]: | |
| st.error("Price field missing in quote data.") | |
| today_price = qdata[0]['price'] | |
| if 'sharesOutstanding' in qdata[0]: | |
| today_shares = qdata[0]['sharesOutstanding'] | |
| elif 'numberOfShares' in qdata[0]: | |
| today_shares = qdata[0]['numberOfShares'] | |
| else: | |
| today_shares = None | |
| now = pd.to_datetime('today').normalize() | |
| df_today = pd.DataFrame({'date': [now], | |
| 'stockPrice': [today_price], | |
| 'numberOfShares': [today_shares]}) | |
| df_ev = pd.concat([df_ev, df_today], ignore_index=True) | |
| df_ev.sort_values('date', inplace=True) | |
| else: | |
| st.warning("Quote data not fetched.") | |
| return df_ev | |
| def get_analyst_data(): | |
| data = local_fetch(analyst_url) | |
| if not data: | |
| st.error("Analyst data empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| for col in ['estimatedRevenueLow', 'estimatedRevenueAvg', 'estimatedRevenueHigh']: | |
| if col not in df.columns: | |
| st.error(f"Field '{col}' missing in analyst data.") | |
| return None | |
| df.rename(columns={ | |
| 'estimatedRevenueLow': 'Forecast_Revenue_Low', | |
| 'estimatedRevenueAvg': 'Forecast_Revenue_Avg', | |
| 'estimatedRevenueHigh': 'Forecast_Revenue_High' | |
| }, inplace=True) | |
| return df | |
| def get_forecast_rows(d, df_analyst): | |
| future = df_analyst[df_analyst['date'] > d].sort_values('date') | |
| if forecast_type == "quarter": | |
| future = future.head(4) | |
| else: | |
| future = future.head(1) | |
| if future.empty: | |
| return [], [], [] | |
| low_list = future['Forecast_Revenue_Low'].tolist() | |
| avg_list = future['Forecast_Revenue_Avg'].tolist() | |
| high_list = future['Forecast_Revenue_High'].tolist() | |
| while len(low_list) < 4: low_list.append(np.nan) | |
| while len(avg_list) < 4: avg_list.append(np.nan) | |
| while len(high_list) < 4: high_list.append(np.nan) | |
| return low_list, avg_list, high_list | |
| df_income = get_income_data() | |
| df_ev = get_ev_data() | |
| if df_income is None or df_ev is None: | |
| return | |
| df_ev = extend_ev_today(df_ev) | |
| df_trailing = pd.merge(df_income[['date', 'Revenue_raw', 'TTM_Revenue']], | |
| df_ev[['date', 'stockPrice', 'numberOfShares']], on='date', how='inner') | |
| df_trailing['Trailing_PS'] = (df_trailing['stockPrice'] * df_trailing['numberOfShares']) / df_trailing['TTM_Revenue'] | |
| df_analyst = get_analyst_data() | |
| if df_analyst is None: | |
| return | |
| for c in ['RevenueLow_1','RevenueLow_2','RevenueLow_3','RevenueLow_4', | |
| 'RevenueAvg_1','RevenueAvg_2','RevenueAvg_3','RevenueAvg_4', | |
| 'RevenueHigh_1','RevenueHigh_2','RevenueHigh_3','RevenueHigh_4']: | |
| df_ev[c] = np.nan | |
| for i in range(len(df_ev)): | |
| d = df_ev.loc[i, 'date'] | |
| lows, avgs, highs = get_forecast_rows(d, df_analyst) | |
| df_ev.at[i, 'RevenueLow_1'] = lows[0] | |
| df_ev.at[i, 'RevenueLow_2'] = lows[1] | |
| df_ev.at[i, 'RevenueLow_3'] = lows[2] | |
| df_ev.at[i, 'RevenueLow_4'] = lows[3] | |
| df_ev.at[i, 'RevenueAvg_1'] = avgs[0] | |
| df_ev.at[i, 'RevenueAvg_2'] = avgs[1] | |
| df_ev.at[i, 'RevenueAvg_3'] = avgs[2] | |
| df_ev.at[i, 'RevenueAvg_4'] = avgs[3] | |
| df_ev.at[i, 'RevenueHigh_1'] = highs[0] | |
| df_ev.at[i, 'RevenueHigh_2'] = highs[1] | |
| df_ev.at[i, 'RevenueHigh_3'] = highs[2] | |
| df_ev.at[i, 'RevenueHigh_4'] = highs[3] | |
| df_ev['ForwardTTM_Low'] = df_ev[['RevenueLow_1','RevenueLow_2','RevenueLow_3','RevenueLow_4']].sum(axis=1, min_count=1) | |
| df_ev['ForwardTTM_Avg'] = df_ev[['RevenueAvg_1','RevenueAvg_2','RevenueAvg_3','RevenueAvg_4']].sum(axis=1, min_count=1) | |
| df_ev['ForwardTTM_High'] = df_ev[['RevenueHigh_1','RevenueHigh_2','RevenueHigh_3','RevenueHigh_4']].sum(axis=1, min_count=1) | |
| df_ev['Forward_PS_Low'] = df_ev.apply(lambda row: (row['stockPrice'] * row['numberOfShares']) / row['ForwardTTM_Low'] | |
| if pd.notna(row['stockPrice']) and pd.notna(row['numberOfShares']) and pd.notna(row['ForwardTTM_Low']) and row['ForwardTTM_Low'] > 0 else np.nan, axis=1) | |
| df_ev['Forward_PS_Avg'] = df_ev.apply(lambda row: (row['stockPrice'] * row['numberOfShares']) / row['ForwardTTM_Avg'] | |
| if pd.notna(row['stockPrice']) and pd.notna(row['numberOfShares']) and pd.notna(row['ForwardTTM_Avg']) and row['ForwardTTM_Avg'] > 0 else np.nan, axis=1) | |
| df_ev['Forward_PS_High'] = df_ev.apply(lambda row: (row['stockPrice'] * row['numberOfShares']) / row['ForwardTTM_High'] | |
| if pd.notna(row['stockPrice']) and pd.notna(row['numberOfShares']) and pd.notna(row['ForwardTTM_High']) and row['ForwardTTM_High'] > 0 else np.nan, axis=1) | |
| df_final = pd.merge(df_trailing, df_ev, on='date', how='outer', suffixes=('_trail', '_fwd')) | |
| df_final.sort_values('date', inplace=True) | |
| if 'stockPrice_trail' in df_final.columns and 'stockPrice_fwd' in df_final.columns: | |
| df_final['stockPrice'] = df_final['stockPrice_trail'].fillna(df_final['stockPrice_fwd']) | |
| df_final.drop(columns=['stockPrice_trail','stockPrice_fwd'], inplace=True) | |
| date_set = set(df_income['date']) | set(df_trailing['date']) | set(df_ev['date']) | |
| df_final = df_final[df_final['date'].isin(date_set)].copy() | |
| start_date_str = df_final['date'].min().strftime('%Y-%m-%d') | |
| end_date_str = df_final['date'].max().strftime('%Y-%m-%d') | |
| daily_url = f"https://financialmodelingprep.com/api/v3/historical-price-full/{TICKER}?from={start_date_str}&to={end_date_str}&serietype=line&apikey={API_KEY}" | |
| daily_data = local_fetch(daily_url) | |
| df_daily = pd.DataFrame(daily_data.get('historical', [])) | |
| if not df_daily.empty: | |
| df_daily['date'] = pd.to_datetime(df_daily['date']) | |
| df_daily.sort_values('date', inplace=True) | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Trailing_PS'], | |
| mode='lines+markers', name='Trailing P/S', line=dict(width=2), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_PS_Low'], | |
| mode='lines+markers', name='Forward P/S (Low)', line=dict(width=1), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_PS_Avg'], | |
| mode='lines+markers', name='Forward P/S (Avg)', line=dict(width=1), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_PS_High'], | |
| mode='lines+markers', name='Forward P/S (High)', line=dict(width=1), yaxis="y1")) | |
| if not df_daily.empty: | |
| fig.add_trace(go.Scatter(x=df_daily['date'], y=df_daily['close'], | |
| mode='lines', name='Daily Stock Price', line=dict(width=1), opacity=0.2, yaxis="y2")) | |
| if forecast_type=="quarter": | |
| fig.update_xaxes(tickformat="%Y-%m", dtick="M3") | |
| else: | |
| fig.update_xaxes(tickformat="%Y", dtick="M12") | |
| fig.update_layout( | |
| title=f"{TICKER} Trailing vs Forward P/S (Low/Avg/High) + Daily Stock ({forecast_type.capitalize()} freq)", | |
| xaxis=dict(title="Date"), | |
| yaxis=dict(title="P/S Ratio", side="left"), | |
| yaxis2=dict(title="Stock Price (Daily)", overlaying="y", side="right"), | |
| template="plotly_dark", legend=dict(x=0.02, y=0.98) | |
| ) | |
| # Dynamic Interpretation string (including all elements from your original code) | |
| # Filter for valid rows | |
| df_valid = df_final[ | |
| df_final[['Trailing_PS', 'Forward_PS_Low', 'Forward_PS_Avg', 'Forward_PS_High']].notna().any(axis=1) & | |
| df_final['date'].notna() | |
| ] | |
| if not df_valid.empty: | |
| latest_row = df_valid.iloc[-1] | |
| latest_date_obj = latest_row['date'] | |
| latest_date_str = latest_date_obj.strftime('%Y-%m-%d') | |
| ps_trailing = latest_row['Trailing_PS'] | |
| ps_fwd_low = latest_row['Forward_PS_Low'] | |
| ps_fwd_avg = latest_row['Forward_PS_Avg'] | |
| ps_fwd_high = latest_row['Forward_PS_High'] | |
| interp_text = f"""--- Latest Combined P/S Interpretation for {TICKER} as of {latest_date_str} ({forecast_type}) --- | |
| Trailing P/S: {ps_trailing:.2f} | |
| Forward P/S (Avg): {ps_fwd_avg:.2f} | |
| Forward P/S Range: [{ps_fwd_low:.2f} – {ps_fwd_high:.2f}] (Spread: {(ps_fwd_high-ps_fwd_low):.2f}) | |
| """ | |
| if ps_trailing < 3: | |
| interp_text += "- Trailing P/S is relatively low. Market isn't pricing sales at a large premium.\n" | |
| elif ps_trailing > 10: | |
| interp_text += "- Trailing P/S is high. Investors are paying a significant multiple on historical revenue.\n" | |
| else: | |
| interp_text += "- Trailing P/S is in a moderate range. Valuation relative to past revenue is balanced.\n" | |
| if ps_fwd_avg < 3: | |
| interp_text += "- Forward P/S is modest. Market expects revenue to grow into the current valuation.\n" | |
| elif ps_fwd_avg > 10: | |
| interp_text += "- Forward P/S is high. Expectations may be aggressive relative to upcoming sales.\n" | |
| else: | |
| interp_text += "- Forward P/S (Avg) is in-line with historical norms.\n" | |
| if pd.notna(ps_fwd_low) and pd.notna(ps_fwd_high): | |
| spread = ps_fwd_high - ps_fwd_low | |
| interp_text += f"Forward P/S Range: {ps_fwd_low:.2f} – {ps_fwd_high:.2f} (Spread: {spread:.2f})\n" | |
| if spread > 2: | |
| interp_text += "- Analyst dispersion on forward sales is wide. Potential uncertainty in top-line forecasts.\n" | |
| else: | |
| interp_text += "- Tight range in forecasts. Suggests consistency in expected growth.\n" | |
| else: | |
| interp_text = "--- No valid combined P/S data available for interpretation. ---\n" | |
| # Extra interpretation block for today's forward-only row | |
| df_today_row = df_final[ | |
| (df_final['date'] == pd.to_datetime('today').normalize()) & | |
| df_final[['Forward_PS_Low', 'Forward_PS_Avg', 'Forward_PS_High']].notna().any(axis=1) | |
| ] | |
| if not df_today_row.empty: | |
| row_today = df_today_row.iloc[0] | |
| fwd_only_date = row_today['date'].strftime('%Y-%m-%d') | |
| fwd_low = row_today['Forward_PS_Low'] | |
| fwd_avg = row_today['Forward_PS_Avg'] | |
| fwd_high = row_today['Forward_PS_High'] | |
| extra_text = f"""--- Forward-Only P/S Snapshot for {TICKER} as of {fwd_only_date} ({forecast_type}) --- | |
| Forward P/S (Avg): {fwd_avg:.2f} | |
| Forward P/S Range: {fwd_low:.2f} – {fwd_high:.2f} (Spread: {(fwd_high-fwd_low):.2f}) | |
| """ | |
| interp_text += "\n" + extra_text | |
| interp_text += f"\n[Summary] {TICKER} ({forecast_type}): Trailing P/S = {ps_trailing:.2f}" | |
| st.session_state.ps_result = {"df_final": df_final, "fig": fig, "interpretation": interp_text} | |
| st.success("P/S Ratio analysis complete.") | |
| if st.session_state.ps_result is not None: | |
| # Single Methodology expander | |
| st.plotly_chart(st.session_state.ps_result["fig"], use_container_width=True) | |
| # Single Dynamic Interpretation expander | |
| with st.expander("Dynamic Interpretation", expanded=False): | |
| st.text(st.session_state.ps_result["interpretation"]) | |
| st.dataframe(st.session_state.ps_result["df_final"]) | |
| # ============================================================================= | |
| # Page 5 – EV/EBIT | |
| # ============================================================================= | |
| def ev_ebit_page(): | |
| #st.markdown("---") | |
| st.header("EV/EBIT Ratio") | |
| st.write( | |
| "This page computes trailing and forward EV/EBIT ratios. " | |
| "The ratio measures how the market is valuing a company relative to its operating earnings. " | |
| "Trailing EBIT is based on reported figures. Forward EBIT comes from analyst forecasts. " | |
| "Used to compare valuation across time or versus peers, especially in capital-intensive sectors." | |
| ) | |
| st.info( | |
| "Chart legend items can be clicked to toggle series on/off. " | |
| "Hover to inspect exact values. Zoom or pan to focus on specific periods." | |
| ) | |
| with st.expander("Methodology", expanded=False): | |
| st.markdown("### Methodology: EV/EBIT Ratio") | |
| st.markdown( | |
| "This chart tracks valuation relative to operating profit using both historical and forecast inputs. " | |
| "Helps assess how market expectations evolve over time." | |
| ) | |
| st.markdown("#### 1. EBIT: Operating Profit as Earnings Base") | |
| st.markdown( | |
| "EBIT is taken from the income statement (`ebit` or `operatingIncome`). " | |
| "Trailing values are summed over the last 4 quarters to form TTM EBIT." | |
| ) | |
| st.markdown("##### Formula (quarterly)") | |
| st.latex(r"TTM\,EBIT_t = \sum_{i=0}^{3} EBIT_{t-i}") | |
| st.markdown("---") | |
| st.markdown("#### 2. Enterprise Value (EV)") | |
| st.markdown("EV reflects market capitalization plus net debt:") | |
| st.latex(r"EV_t = Market\,Cap_t + Total\,Debt_t - Cash_t") | |
| st.markdown("---") | |
| st.markdown("#### 3. Trailing EV/EBIT Ratio") | |
| st.markdown("##### Formula") | |
| st.latex(r"Trailing\,EV/EBIT_t = \frac{EV_t}{TTM\,EBIT_t}") | |
| st.markdown("##### Interpretation") | |
| st.markdown( | |
| "- High EV/EBIT → stock is expensive relative to operating earnings. " | |
| "May reflect strong earnings visibility, brand value, or perceived defensibility." | |
| ) | |
| st.markdown( | |
| "- Low EV/EBIT → stock appears cheaper. Could signal undervaluation, uncertainty, or operational issues." | |
| ) | |
| st.markdown( | |
| "- EV/EBIT < 10 is often flagged as cheap; > 20 may suggest the market is pricing in growth or quality premiums." | |
| ) | |
| st.markdown("- Always consider sector context — norms vary widely across industries.") | |
| st.markdown("---") | |
| st.markdown("#### 4. Forward EV/EBIT Ratio") | |
| st.markdown("Forecast EBIT is aggregated from analyst estimates.") | |
| st.markdown("##### Formula") | |
| st.latex(r"Forward\,EBIT^{(X)}_t = \sum_{i=1}^{4} Forecast\,EBIT_{t+i}^{(X)}") | |
| st.markdown("Then:") | |
| st.latex(r"Forward\,EV/EBIT^{(X)}_t = \frac{EV_t}{Forward\,EBIT^{(X)}_t}") | |
| st.markdown("---") | |
| st.markdown("#### 5. Interpretation Guidelines") | |
| st.markdown( | |
| "- EV/EBIT measures market valuation relative to core earnings.\n" | |
| "- Lower values → possibly underpriced, but confirm EBIT quality.\n" | |
| "- Higher values → may reflect confidence in sustained profitability or structural advantages." | |
| ) | |
| st.markdown("- Use forward values to gauge if market is pricing in improvement or deterioration.") | |
| st.markdown("---") | |
| st.markdown("#### 6. Practical Behavior") | |
| st.markdown( | |
| "- Track changes in EV/EBIT alongside forecast dispersion.\n" | |
| "- Sharp drops in the ratio with flat EV could signal improved forecasts.\n" | |
| "- Sudden spikes with no change in EBIT → valuation expansion or sentiment shift.\n" | |
| "- Pair with margin trends to check if EBIT growth is sustainable." | |
| ) | |
| st.markdown("---") | |
| st.markdown("#### 7. Usage Tips") | |
| st.markdown( | |
| "- Use when net income includes distortions (e.g. taxes, one-offs).\n" | |
| "- Works well in capital-heavy sectors or where leverage is significant.\n" | |
| "- Combine with return on capital to check if valuation is justified.\n" | |
| "- Be cautious comparing across firms with different debt loads or capex cycles." | |
| ) | |
| # No extra sidebar parameters for this page. | |
| if "ev_ebit_result" not in st.session_state: | |
| st.session_state.ev_ebit_result = None | |
| if run_analysis: | |
| with st.spinner("Running EV/EBIT analysis..."): | |
| LIMIT = years_back * (4 if forecast_type == "quarter" else 1) | |
| TICKER = ticker.upper() | |
| if forecast_type == "quarter": | |
| period_str = "quarter" | |
| income_url = f"https://financialmodelingprep.com/api/v3/income-statement/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=quarter&limit={LIMIT}&apikey={API_KEY}" | |
| analyst_url = f"https://financialmodelingprep.com/api/v3/analyst-estimates/{TICKER}?period=quarter&apikey={API_KEY}" | |
| else: | |
| period_str = "annual" | |
| income_url = f"https://financialmodelingprep.com/api/v3/income-statement/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| ev_url = f"https://financialmodelingprep.com/api/v3/enterprise-values/{TICKER}?period=annual&limit={LIMIT}&apikey={API_KEY}" | |
| analyst_url = f"https://financialmodelingprep.com/api/v3/analyst-estimates/{TICKER}?period=annual&apikey={API_KEY}" | |
| quote_url = f"https://financialmodelingprep.com/api/v3/quote/{TICKER}?apikey={API_KEY}" | |
| def local_fetch(url): | |
| return fetch_data(url) | |
| def get_income_data(): | |
| data = local_fetch(income_url) | |
| if not data: | |
| st.error("Income statement data is empty!") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| if 'ebit' in df.columns: | |
| ebit_field = 'ebit' | |
| elif 'operatingIncome' in df.columns: | |
| ebit_field = 'operatingIncome' | |
| else: | |
| st.error("Neither 'ebit' nor 'operatingIncome' found in income statement.") | |
| return None | |
| df.rename(columns={ebit_field: 'EBIT_raw'}, inplace=True) | |
| df['TTM_EBIT'] = df['EBIT_raw'].rolling(4).sum() if forecast_type == "quarter" else df['EBIT_raw'] | |
| df.dropna(subset=['TTM_EBIT'], inplace=True) | |
| return df | |
| def get_ev_data(): | |
| data = local_fetch(ev_url) | |
| if not data: | |
| st.error("EV data is empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| if 'enterpriseValue' not in df.columns: | |
| st.error("Field 'enterpriseValue' missing in EV data.") | |
| return None | |
| return df[['date', 'enterpriseValue']] | |
| def extend_ev_today(df_ev): | |
| qdata = local_fetch(quote_url) | |
| if qdata: | |
| ev_today = qdata[0].get('enterpriseValue', None) | |
| now = pd.to_datetime('today').normalize() | |
| df_today = pd.DataFrame({'date': [now], 'enterpriseValue': [ev_today]}) | |
| df_ev = pd.concat([df_ev, df_today], ignore_index=True) | |
| df_ev.sort_values('date', inplace=True) | |
| else: | |
| st.warning("Quote data not available.") | |
| return df_ev | |
| def get_analyst_data(): | |
| data = local_fetch(analyst_url) | |
| if not data: | |
| st.error("Analyst estimates data is empty.") | |
| return None | |
| df = pd.DataFrame(data) | |
| df['date'] = pd.to_datetime(df['date']) | |
| df.sort_values('date', inplace=True) | |
| for col in ['estimatedEbitLow', 'estimatedEbitAvg', 'estimatedEbitHigh']: | |
| if col not in df.columns: | |
| st.error(f"Field '{col}' missing in analyst data for EBIT.") | |
| return None | |
| df.rename(columns={'estimatedEbitLow': 'Forecast_EBIT_Low', | |
| 'estimatedEbitAvg': 'Forecast_EBIT_Avg', | |
| 'estimatedEbitHigh': 'Forecast_EBIT_High'}, inplace=True) | |
| return df | |
| def get_future_ebit(date_val, df_analyst): | |
| future = df_analyst[df_analyst['date'] > date_val].sort_values('date') | |
| if forecast_type == "quarter": | |
| future = future.head(4) | |
| else: | |
| future = future.head(1) | |
| if future.empty: | |
| return [], [], [] | |
| lows = future['Forecast_EBIT_Low'].tolist() | |
| avgs = future['Forecast_EBIT_Avg'].tolist() | |
| highs = future['Forecast_EBIT_High'].tolist() | |
| while len(lows) < 4: lows.append(np.nan) | |
| while len(avgs) < 4: avgs.append(np.nan) | |
| while len(highs) < 4: highs.append(np.nan) | |
| return lows, avgs, highs | |
| df_income = get_income_data() | |
| df_ev = get_ev_data() | |
| if df_income is None or df_ev is None: | |
| return | |
| df_ev = extend_ev_today(df_ev) | |
| df_trailing = pd.merge(df_income[['date', 'EBIT_raw', 'TTM_EBIT']], | |
| df_ev, on='date', how='inner') | |
| df_trailing['Trailing_EV_EBIT'] = df_trailing.apply( | |
| lambda row: row['enterpriseValue'] / row['TTM_EBIT'] | |
| if pd.notna(row['enterpriseValue']) and pd.notna(row['TTM_EBIT']) and row['TTM_EBIT'] != 0 else np.nan, | |
| axis=1 | |
| ) | |
| df_analyst = get_analyst_data() | |
| if df_analyst is None: | |
| return | |
| # Add forecast EBIT columns to df_ev | |
| for c in ['EBITLow_1', 'EBITLow_2', 'EBITLow_3', 'EBITLow_4', | |
| 'EBITAvg_1', 'EBITAvg_2', 'EBITAvg_3', 'EBITAvg_4', | |
| 'EBITHigh_1', 'EBITHigh_2', 'EBITHigh_3', 'EBITHigh_4']: | |
| df_ev[c] = np.nan | |
| for i in range(len(df_ev)): | |
| d = df_ev.loc[i, 'date'] | |
| lows, avgs, highs = get_future_ebit(d, df_analyst) | |
| df_ev.at[i, 'EBITLow_1'] = lows[0] | |
| df_ev.at[i, 'EBITLow_2'] = lows[1] | |
| df_ev.at[i, 'EBITLow_3'] = lows[2] | |
| df_ev.at[i, 'EBITLow_4'] = lows[3] | |
| df_ev.at[i, 'EBITAvg_1'] = avgs[0] | |
| df_ev.at[i, 'EBITAvg_2'] = avgs[1] | |
| df_ev.at[i, 'EBITAvg_3'] = avgs[2] | |
| df_ev.at[i, 'EBITAvg_4'] = avgs[3] | |
| df_ev.at[i, 'EBITHigh_1'] = highs[0] | |
| df_ev.at[i, 'EBITHigh_2'] = highs[1] | |
| df_ev.at[i, 'EBITHigh_3'] = highs[2] | |
| df_ev.at[i, 'EBITHigh_4'] = highs[3] | |
| df_ev['ForwardTTM_Low'] = df_ev[['EBITLow_1', 'EBITLow_2', 'EBITLow_3', 'EBITLow_4']].sum(axis=1, min_count=1) | |
| df_ev['ForwardTTM_Avg'] = df_ev[['EBITAvg_1', 'EBITAvg_2', 'EBITAvg_3', 'EBITAvg_4']].sum(axis=1, min_count=1) | |
| df_ev['ForwardTTM_High'] = df_ev[['EBITHigh_1', 'EBITHigh_2', 'EBITHigh_3', 'EBITHigh_4']].sum(axis=1, min_count=1) | |
| df_ev['Forward_EV_EBIT_Low'] = df_ev.apply(lambda row: row['enterpriseValue'] / row['ForwardTTM_Low'] | |
| if pd.notna(row['enterpriseValue']) and pd.notna(row['ForwardTTM_Low']) and row['ForwardTTM_Low'] > 0 else np.nan, | |
| axis=1) | |
| df_ev['Forward_EV_EBIT_Avg'] = df_ev.apply(lambda row: row['enterpriseValue'] / row['ForwardTTM_Avg'] | |
| if pd.notna(row['enterpriseValue']) and pd.notna(row['ForwardTTM_Avg']) and row['ForwardTTM_Avg'] > 0 else np.nan, | |
| axis=1) | |
| df_ev['Forward_EV_EBIT_High'] = df_ev.apply(lambda row: row['enterpriseValue'] / row['ForwardTTM_High'] | |
| if pd.notna(row['enterpriseValue']) and pd.notna(row['ForwardTTM_High']) and row['ForwardTTM_High'] > 0 else np.nan, | |
| axis=1) | |
| df_final = pd.merge(df_trailing, df_ev, on='date', how='outer', suffixes=('_trailing', '_fwd')) | |
| df_final.sort_values('date', inplace=True) | |
| if 'enterpriseValue_trailing' in df_final.columns and 'enterpriseValue_fwd' in df_final.columns: | |
| df_final['enterpriseValue'] = df_final['enterpriseValue_trailing'].fillna(df_final['enterpriseValue_fwd']) | |
| df_final.drop(columns=['enterpriseValue_trailing', 'enterpriseValue_fwd'], inplace=True) | |
| date_set = set(df_income['date']) | set(df_trailing['date']) | set(df_ev['date']) | |
| df_final = df_final[df_final['date'].isin(date_set)].copy() | |
| start_date_str = df_final['date'].min().strftime('%Y-%m-%d') | |
| end_date_str = df_final['date'].max().strftime('%Y-%m-%d') | |
| daily_url = f"https://financialmodelingprep.com/api/v3/historical-price-full/{TICKER}?from={start_date_str}&to={end_date_str}&serietype=line&apikey={API_KEY}" | |
| daily_data = local_fetch(daily_url) | |
| df_daily = pd.DataFrame(daily_data.get('historical', [])) | |
| if not df_daily.empty: | |
| df_daily['date'] = pd.to_datetime(df_daily['date']) | |
| df_daily.sort_values('date', inplace=True) | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Trailing_EV_EBIT'], | |
| mode='lines+markers', name='Trailing EV/EBIT', line=dict(width=2), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_EV_EBIT_Low'], | |
| mode='lines+markers', name='Forward EV/EBIT (Low)', line=dict(width=1), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_EV_EBIT_Avg'], | |
| mode='lines+markers', name='Forward EV/EBIT (Avg)', line=dict(width=1), yaxis="y1")) | |
| fig.add_trace(go.Scatter(x=df_final['date'], y=df_final['Forward_EV_EBIT_High'], | |
| mode='lines+markers', name='Forward EV/EBIT (High)', line=dict(width=1), yaxis="y1")) | |
| if not df_daily.empty: | |
| fig.add_trace(go.Scatter(x=df_daily['date'], y=df_daily['close'], | |
| mode='lines', name='Daily Stock Price', line=dict(width=1), opacity=0.2, yaxis="y2")) | |
| if forecast_type == "quarter": | |
| fig.update_xaxes(tickformat="%Y-%m", dtick="M3") | |
| else: | |
| fig.update_xaxes(tickformat="%Y", dtick="M12") | |
| fig.update_layout( | |
| title=f"{TICKER} EV/EBIT (Trailing & Forward Low/Avg/High) + Daily Stock ({'Quarterly' if forecast_type=='quarter' else 'Annual'})", | |
| xaxis=dict(title="Date"), | |
| yaxis=dict(title="EV/EBIT Ratio", side="left"), | |
| yaxis2=dict(title="Stock Price (Daily)", overlaying="y", side="right"), | |
| template="plotly_dark", legend=dict(x=0.02, y=0.98) | |
| ) | |
| # Build dynamic interpretation string using the provided interpretation block | |
| interp_parts = [] | |
| # Filter valid rows for EV/EBIT interpretation | |
| df_latest_valid = df_final[ | |
| df_final[['Trailing_EV_EBIT', 'Forward_EV_EBIT_Low', 'Forward_EV_EBIT_Avg', 'Forward_EV_EBIT_High']].notna().any(axis=1) & | |
| df_final['date'].notna() | |
| ] | |
| if not df_latest_valid.empty: | |
| latest_row = df_latest_valid.iloc[-1] | |
| latest_date = latest_row['date'].strftime('%Y-%m-%d') | |
| trailing = latest_row['Trailing_EV_EBIT'] | |
| fwd_low = latest_row['Forward_EV_EBIT_Low'] | |
| fwd_avg = latest_row['Forward_EV_EBIT_Avg'] | |
| fwd_high = latest_row['Forward_EV_EBIT_High'] | |
| interp_parts.append(f"--- EV/EBIT Interpretation for {TICKER} on {latest_date} ({forecast_type.capitalize()}) ---") | |
| if pd.notna(trailing): | |
| interp_parts.append(f"Trailing EV/EBIT: {trailing:.2f}") | |
| if trailing < 8: | |
| interp_parts.append(f"- EV/EBIT is low → {TICKER} may be priced conservatively relative to trailing EBIT.") | |
| elif trailing > 20: | |
| interp_parts.append("- EV/EBIT is elevated → market might be pricing in strong margin durability or strategic optionality.") | |
| else: | |
| interp_parts.append("- EV/EBIT falls in a typical range → stable trailing profitability is reflected in current pricing.") | |
| if pd.notna(fwd_avg): | |
| interp_parts.append(f"Forward EV/EBIT (Avg): {fwd_avg:.2f}") | |
| if fwd_avg < 10: | |
| interp_parts.append(f"- Forecast EBIT implies reasonable forward valuation for {TICKER}.") | |
| elif fwd_avg > 20: | |
| interp_parts.append("- Forward EV/EBIT is high → price may reflect expected growth, margin upside, or non-operating asset value.") | |
| else: | |
| interp_parts.append("- Market valuation appears aligned with forecast EBIT expectations.") | |
| if pd.notna(fwd_low) and pd.notna(fwd_high): | |
| spread = fwd_high - fwd_low | |
| interp_parts.append(f"Forward EV/EBIT Range: {fwd_low:.2f} – {fwd_high:.2f} (Spread: {spread:.2f})") | |
| if spread > 5: | |
| interp_parts.append("- Forecast dispersion is high. Analyst expectations around EBIT vary significantly.") | |
| else: | |
| interp_parts.append("- Forecasts are consistent. Market may have strong consensus around earnings trajectory.") | |
| else: | |
| interp_parts.append("--- No valid combined EV/EBIT data available for interpretation. ---") | |
| # Extra forward-only snapshot for today | |
| df_today_row = df_final[ | |
| (df_final['date'] == pd.to_datetime('today').normalize()) & | |
| df_final[['Forward_EV_EBIT_Low', 'Forward_EV_EBIT_Avg', 'Forward_EV_EBIT_High']].notna().any(axis=1) | |
| ] | |
| if not df_today_row.empty: | |
| row_today = df_today_row.iloc[0] | |
| today_date = row_today['date'].strftime('%Y-%m-%d') | |
| fwd_low_today = row_today['Forward_EV_EBIT_Low'] | |
| fwd_avg_today = row_today['Forward_EV_EBIT_Avg'] | |
| fwd_high_today = row_today['Forward_EV_EBIT_High'] | |
| interp_parts.append(f"\n--- Forward EV/EBIT Snapshot for {TICKER} on {today_date} ---") | |
| if pd.notna(fwd_avg_today): | |
| interp_parts.append(f"Forward EV/EBIT (Avg): {fwd_avg_today:.2f}") | |
| if fwd_avg_today < 10: | |
| interp_parts.append("- Latest valuation reflects modest EBIT expectations.") | |
| elif fwd_avg_today > 20: | |
| interp_parts.append("- High multiple suggests the market may be leaning into positive revisions or optionality.") | |
| else: | |
| interp_parts.append("- Forward valuation appears neutral.") | |
| if pd.notna(fwd_low_today) and pd.notna(fwd_high_today): | |
| spread_today = fwd_high_today - fwd_low_today | |
| interp_parts.append(f"Range: {fwd_low_today:.2f} – {fwd_high_today:.2f} (Spread: {spread_today:.2f})") | |
| if spread_today > 5: | |
| interp_parts.append("- Wide range in estimates implies uncertainty or debate around operating leverage.") | |
| else: | |
| interp_parts.append("- Estimates are tightly grouped. Market outlook is more aligned.") | |
| # Final summary line | |
| if df_latest_valid.empty: | |
| summary_line = "[Summary] No valid EV/EBIT data available." | |
| else: | |
| summary_line = f"[Summary] {TICKER} ({period_str.capitalize()}): Trailing EV/EBIT = {trailing:.2f}, Forward EV/EBIT (Avg) = {fwd_avg:.2f}" | |
| interp_parts.append("\n" + summary_line) | |
| interpretation = "\n".join(interp_parts) | |
| st.session_state.ev_ebit_result = { | |
| "df_final": df_final, | |
| "fig": fig, | |
| "interpretation": interpretation | |
| } | |
| st.success("EV/EBIT analysis complete.") | |
| if st.session_state.ev_ebit_result is not None: | |
| st.plotly_chart(st.session_state.ev_ebit_result["fig"], use_container_width=True) | |
| with st.expander("Dynamic Interpretation", expanded=False): | |
| st.text(st.session_state.ev_ebit_result["interpretation"]) | |
| st.dataframe(st.session_state.ev_ebit_result["df_final"]) | |
| # ============================================================================= | |
| # Main: Call the selected page function | |
| # ============================================================================= | |
| if page == "P/E & PEG": | |
| with st.container(border=True): | |
| pe_peg_page() | |
| elif page == "EV/EBITDA": | |
| with st.container(border=True): | |
| ev_ebitda_page() | |
| elif page == "P/B Ratio": | |
| with st.container(border=True): | |
| pb_ratio_page() | |
| elif page == "P/S Ratio": | |
| with st.container(border=True): | |
| ps_ratio_page() | |
| elif page == "EV/EBIT": | |
| with st.container(border=True): | |
| ev_ebit_page() | |
| # Hide default Streamlit style | |
| st.markdown( | |
| """ | |
| <style> | |
| #MainMenu {visibility: hidden;} | |
| footer {visibility: hidden;} | |
| </style> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |