Spaces:
Sleeping
Sleeping
| """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 | |