trading-tools / utils /charts /valuation_dashboard.py
Deploy Bot
Deploy Trading Analysis Platform to HuggingFace Spaces
a1bf219
"""Valuation dashboard orchestrator for generating comprehensive fundamental charts.
This module coordinates the generation of 8-9 fundamental charts and assembles them
into a responsive dashboard layout.
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
import yfinance as yf
from config.models import (
ChartData,
DashboardLayout,
GridConfig,
ValuationDashboard,
)
from data.providers.fundamental_provider import FundamentalDataProvider
from utils.charts import fundamental_charts
logger = logging.getLogger(__name__)
class ValuationDashboardGenerator:
"""Generates comprehensive valuation dashboard with 8-9 fundamental charts."""
def __init__(self, av_api_key: str | None = None) -> None:
"""Initialize dashboard generator with data provider.
Sets up the fundamental data provider with optional Alpha Vantage API
key for fallback data access.
Args:
av_api_key: Optional Alpha Vantage API key for data fallback when
yfinance data is unavailable.
Example:
>>> generator = ValuationDashboardGenerator()
>>> generator = ValuationDashboardGenerator(av_api_key="YOUR_KEY")
"""
self.provider = FundamentalDataProvider(av_api_key=av_api_key)
logger.info("ValuationDashboardGenerator initialized")
def generate(
self, ticker: str, start_date: datetime, end_date: datetime
) -> ValuationDashboard:
"""Generate complete valuation dashboard with all charts.
Args:
ticker: Stock ticker symbol
start_date: Start date for charts
end_date: End date for charts
Returns:
ValuationDashboard with all generated charts
Raises:
ValueError: If ticker is invalid or data unavailable
"""
logger.info(
f"Generating valuation dashboard for {ticker} ({start_date.date()} to {end_date.date()})"
)
start_time = datetime.now()
# Fetch fundamental data
data_fetch_start = datetime.now()
fundamental_data = self._fetch_fundamental_data(ticker, start_date, end_date)
data_fetch_time = (datetime.now() - data_fetch_start).total_seconds()
logger.info(f"Data fetch completed in {data_fetch_time:.2f}s")
# Generate all charts
chart_gen_start = datetime.now()
charts = self._generate_all_charts(
ticker, fundamental_data, start_date, end_date
)
chart_gen_time = (datetime.now() - chart_gen_start).total_seconds()
logger.info(
f"Chart generation completed in {chart_gen_time:.2f}s ({len(charts)} charts, avg {chart_gen_time / len(charts):.2f}s per chart)"
)
# Create responsive layout configuration
layout_config = self._create_responsive_layout()
# Determine data source
data_source = self._determine_data_source(charts)
dashboard = ValuationDashboard(
ticker=ticker,
start_date=start_date.isoformat(),
end_date=end_date.isoformat(),
charts=charts,
layout_config=layout_config,
generation_timestamp=datetime.now().isoformat(),
data_source=data_source,
)
total_time = (datetime.now() - start_time).total_seconds()
logger.info(
f"Dashboard generated successfully: {len(charts)} charts, source: {data_source}, total time: {total_time:.2f}s"
)
return dashboard
def _fetch_fundamental_data(
self, ticker: str, start_date: datetime, end_date: datetime
) -> dict[str, list[tuple[datetime, float | None]]]:
"""Fetch historical fundamental data from yfinance.
Note: For fundamental metrics (margins, ROE, FCF, etc.), we fetch ALL available
quarterly data regardless of the display date range, because:
- Quarterly reports are sparse (~92 days apart)
- A 90-day swing trading window might contain 0-1 reports
- Showing historical context improves analysis
The chart axes will still be set to start_date/end_date for alignment.
Args:
ticker: Stock ticker symbol
start_date: Start date for chart display range
end_date: End date for chart display range
Returns:
Dictionary with quarterly fundamental data time-series
"""
logger.info(f"Fetching fundamental data for {ticker}")
try:
stock = yf.Ticker(ticker)
# Fetch quarterly financials (contains Revenue, Gross Profit, Net Income, etc.)
quarterly_financials = stock.quarterly_financials
quarterly_balance_sheet = stock.quarterly_balance_sheet
quarterly_cashflow = stock.quarterly_cashflow
info = stock.info
# Get historical price data for calculating valuation ratios
# Fetch daily price history for the date range
days_back = (end_date - start_date).days + 30 # Add 30-day buffer
hist = stock.history(period=f"{days_back}d", interval="1d")
# Initialize time-series lists
pe_ratio_series = []
pb_ratio_series = []
ps_ratio_series = []
ev_ebitda_series = [] # Keep current value only (complex to calculate historically)
gross_margin_series = []
operating_margin_series = []
net_margin_series = []
roe_series = []
revenue_growth_series = []
earnings_growth_series = []
fcf_series = []
debt_to_equity_series = []
# Calculate historical valuation ratios from price data + trailing fundamentals
# This provides daily data points for P/E, P/B, P/S ratios
if not hist.empty:
trailing_eps = info.get("trailingEps")
book_value_per_share = info.get("bookValue")
revenue_per_share = info.get("revenuePerShare")
for date, row in hist.iterrows():
price = row["Close"]
hist_date = date.to_pydatetime().replace(tzinfo=None)
# Only include data within the display date range
if hist_date < start_date or hist_date > end_date:
continue
# Calculate P/E ratio (Price / Trailing EPS)
if trailing_eps and trailing_eps > 0 and price > 0:
pe_ratio_series.append((hist_date, price / trailing_eps))
# Calculate P/B ratio (Price / Book Value per Share)
if book_value_per_share and book_value_per_share > 0 and price > 0:
pb_ratio_series.append(
(hist_date, price / book_value_per_share)
)
# Calculate P/S ratio (Price / Revenue per Share)
if revenue_per_share and revenue_per_share > 0 and price > 0:
ps_ratio_series.append((hist_date, price / revenue_per_share))
# Use current EV/EBITDA value (complex to calculate historically)
current_ev_ebitda = info.get("enterpriseToEbitda")
if current_ev_ebitda:
ev_ebitda_series.append((end_date, current_ev_ebitda))
# Extract quarterly data from financial statements
if not quarterly_financials.empty:
# Get quarter dates (columns in the dataframe)
quarters = quarterly_financials.columns
for quarter_date in quarters:
# Convert pandas Timestamp to datetime
qdate = quarter_date.to_pydatetime()
# NOTE: Do NOT filter by date range here - quarterly data is sparse
# and we want to show all available data points. The chart axes will
# be set to start_date/end_date for temporal alignment.
# Extract metrics for this quarter
try:
# Revenue and earnings for margin calculations
revenue = (
quarterly_financials.loc["Total Revenue", quarter_date]
if "Total Revenue" in quarterly_financials.index
else None
)
gross_profit = (
quarterly_financials.loc["Gross Profit", quarter_date]
if "Gross Profit" in quarterly_financials.index
else None
)
operating_income = (
quarterly_financials.loc["Operating Income", quarter_date]
if "Operating Income" in quarterly_financials.index
else None
)
net_income = (
quarterly_financials.loc["Net Income", quarter_date]
if "Net Income" in quarterly_financials.index
else None
)
# Calculate margins
if revenue and revenue > 0:
if gross_profit:
gross_margin_series.append(
(qdate, (gross_profit / revenue) * 100)
)
if operating_income:
operating_margin_series.append(
(qdate, (operating_income / revenue) * 100)
)
if net_income:
net_margin_series.append(
(qdate, (net_income / revenue) * 100)
)
except (KeyError, ZeroDivisionError):
pass
# Extract balance sheet data
if not quarterly_balance_sheet.empty:
quarters_bs = quarterly_balance_sheet.columns
for quarter_date in quarters_bs:
qdate = quarter_date.to_pydatetime()
# NOTE: Do NOT filter by date range - include all available quarters
try:
# Equity and debt for leverage
total_equity = (
quarterly_balance_sheet.loc[
"Stockholders Equity", quarter_date
]
if "Stockholders Equity" in quarterly_balance_sheet.index
else None
)
total_debt = (
quarterly_balance_sheet.loc["Total Debt", quarter_date]
if "Total Debt" in quarterly_balance_sheet.index
else None
)
total_assets = (
quarterly_balance_sheet.loc["Total Assets", quarter_date]
if "Total Assets" in quarterly_balance_sheet.index
else None
)
# Debt-to-Equity
if total_equity and total_debt and total_equity > 0:
debt_to_equity_series.append(
(qdate, total_debt / total_equity)
)
# ROE calculation (requires net income from financials)
if total_equity and total_equity > 0:
# Try to get net income from same quarter
if (
not quarterly_financials.empty
and quarter_date in quarterly_financials.columns
):
net_income_q = (
quarterly_financials.loc["Net Income", quarter_date]
if "Net Income" in quarterly_financials.index
else None
)
if net_income_q:
# Annualize quarterly net income
annual_net_income = net_income_q * 4
roe_series.append(
(
qdate,
(annual_net_income / total_equity) * 100,
)
)
except (KeyError, ZeroDivisionError):
pass
# Extract cash flow data
if not quarterly_cashflow.empty:
quarters_cf = quarterly_cashflow.columns
for quarter_date in quarters_cf:
qdate = quarter_date.to_pydatetime()
# NOTE: Do NOT filter by date range - include all available quarters
try:
# Free Cash Flow
operating_cf = (
quarterly_cashflow.loc["Operating Cash Flow", quarter_date]
if "Operating Cash Flow" in quarterly_cashflow.index
else None
)
capex = (
quarterly_cashflow.loc["Capital Expenditure", quarter_date]
if "Capital Expenditure" in quarterly_cashflow.index
else None
)
if operating_cf and capex:
# CapEx is usually negative, so subtract it
free_cf = operating_cf - abs(capex)
fcf_series.append(
(qdate, free_cf / 1_000_000)
) # Convert to millions
except KeyError:
pass
# Calculate YoY growth rates if we have enough quarterly data
# We need at least 5 quarters to calculate YoY (current + 4 prior quarters)
if len(quarters) >= 5:
# Calculate growth for ALL quarters that have YoY comparison data
# quarters is sorted newest to oldest, so we iterate backwards
for i in range(len(quarters) - 4):
current_quarter = quarters[i]
prior_year_quarter = quarters[i + 4] # 4 quarters back = 1 year
qdate = current_quarter.to_pydatetime()
# NOTE: Do NOT filter by date range - include all available growth data
try:
# Revenue growth YoY
current_revenue = (
quarterly_financials.loc["Total Revenue", current_quarter]
if "Total Revenue" in quarterly_financials.index
else None
)
prior_revenue = (
quarterly_financials.loc[
"Total Revenue", prior_year_quarter
]
if "Total Revenue" in quarterly_financials.index
else None
)
if current_revenue and prior_revenue and prior_revenue != 0:
rev_growth = (
(current_revenue - prior_revenue) / abs(prior_revenue)
) * 100
revenue_growth_series.append((qdate, rev_growth))
# Earnings growth YoY
current_earnings = (
quarterly_financials.loc["Net Income", current_quarter]
if "Net Income" in quarterly_financials.index
else None
)
prior_earnings = (
quarterly_financials.loc["Net Income", prior_year_quarter]
if "Net Income" in quarterly_financials.index
else None
)
if current_earnings and prior_earnings and prior_earnings != 0:
earn_growth = (
(current_earnings - prior_earnings)
/ abs(prior_earnings)
) * 100
earnings_growth_series.append((qdate, earn_growth))
except (KeyError, ZeroDivisionError):
pass
# NOTE: P/E, P/B, P/S are already calculated from historical prices above
# Only EV/EBITDA needs current value (complex to calculate historically)
# Compile all data
fundamental_data = {
"pe_ratio": pe_ratio_series,
"pb_ratio": pb_ratio_series,
"ps_ratio": ps_ratio_series,
"ev_ebitda": ev_ebitda_series,
"gross_margin": gross_margin_series,
"operating_margin": operating_margin_series,
"net_margin": net_margin_series,
"roe": roe_series,
"revenue_growth": revenue_growth_series,
"earnings_growth": earnings_growth_series,
"fcf": fcf_series,
"debt_to_equity": debt_to_equity_series,
}
# Log data availability
logger.info(
f"Extracted time-series: gross_margin={len(gross_margin_series)} points, "
f"net_margin={len(net_margin_series)} points, "
f"roe={len(roe_series)} points, "
f"revenue_growth={len(revenue_growth_series)} points, "
f"fcf={len(fcf_series)} points"
)
return fundamental_data
except Exception as e:
logger.error(f"Failed to fetch fundamental data for {ticker}: {e}")
raise ValueError(f"Could not fetch fundamental data for {ticker}: {e}")
def _generate_all_charts(
self,
ticker: str,
fundamental_data: dict[str, list[tuple[datetime, float | None]]],
start_date: datetime,
end_date: datetime,
) -> list[ChartData]:
"""Generate 7 fundamental charts for dashboard visualization.
Note: EV/EBITDA and Revenue/Earnings Growth charts are excluded from
the dashboard due to insufficient data points (1-2 points), but the
underlying data is still available in fundamental_data for text analysis.
Args:
ticker: Stock ticker symbol
fundamental_data: Dictionary with historical data
start_date: Chart start date
end_date: Chart end date
Returns:
List of 7 ChartData objects
"""
charts = []
# Chart 1: P/E Ratio
charts.append(
fundamental_charts.generate_pe_ratio_chart(
ticker,
fundamental_data.get("pe_ratio", []),
start_date,
end_date,
)
)
# Chart 2: P/B Ratio
charts.append(
fundamental_charts.generate_pb_ratio_chart(
ticker,
fundamental_data.get("pb_ratio", []),
start_date,
end_date,
)
)
# Chart 3: P/S Ratio
charts.append(
fundamental_charts.generate_ps_ratio_chart(
ticker,
fundamental_data.get("ps_ratio", []),
start_date,
end_date,
)
)
# Chart 4: EV/EBITDA - REMOVED (only 1 data point)
# Data still available in fundamental_data["ev_ebitda"] for text analysis
# Chart 5: Revenue & Earnings Growth - REMOVED (only 1-2 data points)
# Data still available in fundamental_data["revenue_growth"] and
# fundamental_data["earnings_growth"] for text analysis
# Chart 5: Profit Margins (3 lines)
charts.append(
fundamental_charts.generate_profit_margins_chart(
ticker,
fundamental_data.get("gross_margin", []),
fundamental_data.get("operating_margin", []),
fundamental_data.get("net_margin", []),
start_date,
end_date,
)
)
# Chart 4: ROE
charts.append(
fundamental_charts.generate_roe_chart(
ticker,
fundamental_data.get("roe", []),
start_date,
end_date,
)
)
# Chart 5: Free Cash Flow
charts.append(
fundamental_charts.generate_fcf_chart(
ticker,
fundamental_data.get("fcf", []),
start_date,
end_date,
)
)
# Chart 6: Debt-to-Equity
charts.append(
fundamental_charts.generate_debt_equity_chart(
ticker,
fundamental_data.get("debt_to_equity", []),
start_date,
end_date,
)
)
logger.info(f"Generated {len(charts)} charts for {ticker}")
return charts
def _create_responsive_layout(self) -> DashboardLayout:
"""Create responsive grid layout configuration for 6 charts.
Layout: 2x3 grid (desktop/tablet), stacked (mobile)
- Row 1: P/E, P/B
- Row 2: P/S, Profit Margins
- Row 3: ROE, FCF
- Row 4: Debt-to-Equity (centered)
"""
return DashboardLayout(
desktop_config=GridConfig(columns=2, rows=4, min_width=300),
tablet_config=GridConfig(columns=2, rows=4, min_width=300),
mobile_config=GridConfig(columns=1, rows=7), # Changed from 8 to 7
)
def _determine_data_source(self, charts: list[ChartData]) -> str:
"""Determine primary data source used.
Args:
charts: List of generated charts
Returns:
Data source string ("yfinance", "alpha_vantage", or "hybrid")
"""
# For now, always yfinance since we're using yfinance for charts
# Would be "hybrid" if we used Alpha Vantage fallback
return "yfinance"
def _handle_missing_data(self, charts: list[ChartData]) -> list[ChartData]:
"""Handle missing data gracefully by marking availability status.
Args:
charts: List of chart data
Returns:
Updated list with data availability marked
"""
for chart in charts:
# Check if chart has any valid data points
has_data = False
for series in chart.data_series:
if any(dp.value is not None for dp in series.data_points):
has_data = True
break
if not has_data:
logger.warning(
f"Chart {chart.chart_type} has no valid data - marked as unavailable"
)
return charts