Space70 / app.py
QuantumLearner's picture
Update app.py
5c32400 verified
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) -----
@st.cache_data(show_spinner=True)
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
)