Spaces:
Sleeping
Sleeping
| # app.py: Fundamental Analysis Pipeline on Hugging Face Spaces | |
| # Version: 4.0 (Dynamic User Input with Robust Safeguards) | |
| import gradio as gr | |
| import yfinance | |
| import pandas as pd | |
| import numpy as np | |
| import numpy_financial as npf | |
| import warnings | |
| import traceback | |
| # Suppress warnings for a cleaner UI | |
| warnings.simplefilter(action='ignore', category=FutureWarning) | |
| # --- 1. GLOBAL CONFIGURATION & CONSTANTS --- | |
| # Peer groups are still useful for the Comps model if a user enters a supported ticker. | |
| PEER_GROUPS = { | |
| 'CHTR': ['CMCSA', 'T', 'VZ'], 'CRM': ['NOW', 'ADBE', 'MSFT'], 'TTD': ['GOOGL', 'MGNI', 'PUBM'], | |
| 'LULU': ['NKE', 'DECK', 'UAA'], 'FICO': ['V', 'MA', 'AXP'], 'AAPL': ['MSFT', 'GOOGL', 'AMZN'], | |
| 'MSFT': ['AAPL', 'GOOGL', 'AMZN', 'ORCL'], 'GOOGL': ['MSFT', 'AAPL', 'AMZN', 'META'] | |
| } | |
| # DCF Assumptions | |
| RISK_FREE_RATE = 0.042 # Default fallback | |
| MARKET_RISK_PREMIUM = 0.055 | |
| PERPETUAL_GROWTH_RATE = 0.025 | |
| PROJECTION_YEARS = 5 | |
| # --- 2. PIPELINE FUNCTIONS --- | |
| def get_stock_data(ticker_str): | |
| """ | |
| Fetches data for a single stock and validates the ticker. | |
| Returns a tuple: (stock_data_dict, company_name_str) | |
| """ | |
| print(f"LOG: [get_stock_data] Fetching data for {ticker_str}...") | |
| ticker_obj = yfinance.Ticker(ticker_str) | |
| # SAFEGUARD: Validate the ticker by checking the info dictionary. | |
| if not ticker_obj.info or 'symbol' not in ticker_obj.info: | |
| raise ValueError(f"Could not find a valid stock for ticker '{ticker_str}'. Please check the symbol and try again.") | |
| company_name = ticker_obj.info.get('longName', ticker_str) | |
| stock_data = { | |
| 'profile': ticker_obj.info, | |
| 'income_statement': ticker_obj.financials.T, | |
| 'balance_sheet': ticker_obj.balance_sheet.T, | |
| 'cash_flow': ticker_obj.cashflow.T | |
| } | |
| if stock_data['income_statement'].empty: | |
| raise ValueError(f"Financial statements for {ticker_str} are empty. The stock may be a fund, ETF, or have no available data.") | |
| print(f"LOG: [get_stock_data] Data fetch for {company_name} successful.") | |
| return stock_data, company_name | |
| def calculate_metrics(stock_data): | |
| """Calculates key financial ratios from raw statement data.""" | |
| print("LOG: [calculate_metrics] Starting calculation...") | |
| income_df = stock_data['income_statement'] | |
| cash_flow_df = stock_data['cash_flow'] | |
| metrics_df = pd.DataFrame(index=income_df.index) | |
| operating_income = pd.to_numeric(income_df.get('Operating Income'), errors='coerce') | |
| total_revenue = pd.to_numeric(income_df.get('Total Revenue'), errors='coerce') | |
| metrics_df['operatingMargin'] = operating_income / total_revenue | |
| capex = pd.to_numeric(cash_flow_df.get('Capital Expenditure'), errors='coerce').fillna(0) | |
| operating_cash = pd.to_numeric(cash_flow_df.get('Operating Cash Flow'), errors='coerce').fillna(0) | |
| metrics_df['freeCashFlow'] = operating_cash + capex | |
| print("LOG: [calculate_metrics] Calculation successful.") | |
| return metrics_df.sort_index() | |
| def calculate_wacc(stock_data): | |
| """Calculates the Weighted Average Cost of Capital (WACC).""" | |
| print("LOG: [calculate_wacc] Starting WACC calculation...") | |
| try: | |
| profile = stock_data['profile'] | |
| balance_df_latest = stock_data['balance_sheet'].iloc[-1] | |
| income_df_latest = stock_data['income_statement'].iloc[-1] | |
| market_cap = np.nan_to_num(profile.get('marketCap', 0)) | |
| total_debt = np.nan_to_num(balance_df_latest.get('Total Debt', 0)) | |
| beta = np.nan_to_num(profile.get('beta', 1.0)) | |
| interest_expense = np.nan_to_num(income_df_latest.get('Interest Expense', 0)) | |
| income_before_tax = np.nan_to_num(income_df_latest.get('Pretax Income')) | |
| tax_provision = np.nan_to_num(income_df_latest.get('Tax Provision', 0)) | |
| cost_of_equity = RISK_FREE_RATE + beta * MARKET_RISK_PREMIUM | |
| cost_of_debt = abs(interest_expense) / total_debt if total_debt > 0 else 0 | |
| tax_rate = tax_provision / income_before_tax if income_before_tax != 0 else 0.21 | |
| tax_rate = max(0, min(tax_rate, 0.40)) | |
| equity_value = market_cap | |
| debt_value = total_debt | |
| total_value = equity_value + debt_value | |
| if total_value == 0: return None | |
| wacc = (equity_value / total_value * cost_of_equity) + \ | |
| (debt_value / total_value * cost_of_debt * (1 - tax_rate)) | |
| print(f"LOG: [calculate_wacc] WACC calculation successful: {wacc:.3%}") | |
| return wacc | |
| except Exception as e: | |
| print(f"LOG: [calculate_wacc] WACC calculation failed: {e}") | |
| return None | |
| def run_dcf_valuation(stock_data, financial_metrics, wacc): | |
| """Runs the DCF valuation model.""" | |
| print("LOG: [run_dcf_valuation] Starting DCF valuation...") | |
| try: | |
| if wacc is None: return None | |
| last_fcf = financial_metrics['freeCashFlow'].dropna().iloc[-1] if not financial_metrics['freeCashFlow'].dropna().empty else None | |
| if last_fcf is None or last_fcf <= 0: return None | |
| revenue_series = stock_data['income_statement']['Total Revenue'].dropna() | |
| revenue_cagr = (revenue_series.iloc[-1] / revenue_series.iloc[0]) ** (1/len(revenue_series)) - 1 if len(revenue_series) > 1 and revenue_series.iloc[0] > 0 else 0.05 | |
| initial_growth_rate = max(0.01, min(revenue_cagr, 0.20)) | |
| projected_fcf = [] | |
| fcf = last_fcf | |
| for i in range(1, PROJECTION_YEARS + 1): | |
| growth_rate = initial_growth_rate + i * (PERPETUAL_GROWTH_RATE - initial_growth_rate) / PROJECTION_YEARS | |
| fcf *= (1 + growth_rate) | |
| projected_fcf.append(fcf) | |
| terminal_value = (projected_fcf[-1] * (1 + PERPETUAL_GROWTH_RATE)) / (wacc - PERPETUAL_GROWTH_RATE) | |
| if terminal_value < 0: return None | |
| cash_flows = projected_fcf | |
| cash_flows[-1] += terminal_value | |
| enterprise_value = npf.npv(wacc, [0] + cash_flows) | |
| latest_bs = stock_data['balance_sheet'].iloc[-1] | |
| net_debt = np.nan_to_num(latest_bs.get('Total Debt', 0)) - np.nan_to_num(latest_bs.get('Cash And Cash Equivalents', 0)) | |
| shares_outstanding = stock_data['profile']['sharesOutstanding'] | |
| equity_value = enterprise_value - net_debt | |
| print("LOG: [run_dcf_valuation] DCF valuation successful.") | |
| return equity_value / shares_outstanding | |
| except Exception as e: | |
| print(f"LOG: [run_dcf_valuation] DCF valuation failed: {e}") | |
| return None | |
| def run_comps_valuation(stock_data): | |
| """Runs the Comparable Company Analysis.""" | |
| print("LOG: [run_comps_valuation] Starting Comps valuation...") | |
| try: | |
| ticker = stock_data['profile']['symbol'] | |
| peer_list = PEER_GROUPS.get(ticker, []) | |
| if not peer_list: return None | |
| peer_ratios = [yfinance.Ticker(p).info.get('enterpriseToEbitda') for p in peer_list] | |
| median_ev_ebitda = pd.Series([r for r in peer_ratios if r]).median() | |
| target_ebitda = stock_data['profile'].get('ebitda') | |
| shares_outstanding = stock_data['profile'].get('sharesOutstanding') | |
| if not all([median_ev_ebitda, target_ebitda, shares_outstanding]) or target_ebitda <= 0: | |
| return None | |
| implied_ev = target_ebitda * median_ev_ebitda | |
| latest_bs = stock_data['balance_sheet'].iloc[-1] | |
| net_debt = np.nan_to_num(latest_bs.get('Total Debt', 0)) - np.nan_to_num(latest_bs.get('Cash And Cash Equivalents', 0)) | |
| implied_equity_value = implied_ev - net_debt | |
| print("LOG: [run_comps_valuation] Comps valuation successful.") | |
| return implied_equity_value / shares_outstanding | |
| except Exception as e: | |
| print(f"LOG: [run_comps_valuation] Comps failed with an exception: {e}") | |
| return None | |
| # --- 3. MAIN CONTROLLER FUNCTION --- | |
| def analyze_stock(ticker): | |
| """ | |
| The main function that runs the entire pipeline for a given ticker and returns results. | |
| Returns two outputs for Gradio: a Markdown string for the title, and a DataFrame for the table. | |
| """ | |
| if not ticker or not ticker.strip(): | |
| return "", pd.DataFrame({"Error": ["Please enter a ticker symbol."]}) | |
| ticker = ticker.strip().upper() # Sanitize user input | |
| print(f"\n--- [START] Full analysis for {ticker}... ---") | |
| try: | |
| stock_data, company_name = get_stock_data(ticker) | |
| financial_metrics = calculate_metrics(stock_data) | |
| wacc = calculate_wacc(stock_data) | |
| dcf_value = run_dcf_valuation(stock_data, financial_metrics, wacc) | |
| comps_value = run_comps_valuation(stock_data) | |
| current_price = yfinance.Ticker(ticker).history(period='1d')['Close'].iloc[-1] | |
| valid_valuations = [v for v in [dcf_value, comps_value] if v is not None and pd.notna(v) and v > 0] | |
| average_value = sum(valid_valuations) / len(valid_valuations) if valid_valuations else 0 | |
| upside = (average_value / current_price) - 1 if current_price > 0 else 0 | |
| decision = "Buy" if upside > 0.25 else "Hold/Not Buy" | |
| if average_value == 0: decision = "Analysis Failed" | |
| op_margin_series = financial_metrics['operatingMargin'].dropna() | |
| op_margin = op_margin_series.iloc[-1] if not op_margin_series.empty else np.nan | |
| beta = stock_data['profile'].get('beta') | |
| summary_data = { | |
| "Ticker": ticker, "Current Price": f"${current_price:.2f}", | |
| "DCF Value": f"${dcf_value:.2f}" if dcf_value else "N/A", | |
| "Comps Value": f"${comps_value:.2f}" if comps_value else "N/A", | |
| "Avg. Intrinsic Value": f"${average_value:.2f}", | |
| "Upside Potential": f"{upside:.2%}" if average_value > 0 else "N/A", | |
| "Decision": decision, | |
| "Operating Margin": f"{op_margin:.2%}" if pd.notna(op_margin) else "N/A", | |
| "Beta": f"{beta:.2f}" if pd.notna(beta) else "N/A" | |
| } | |
| print(f"--- [SUCCESS] Full analysis for {ticker} complete. ---\n") | |
| # Return both the title and the results DataFrame | |
| return f"## Analysis for: {company_name} ({ticker})", pd.DataFrame([summary_data]).set_index("Ticker") | |
| except Exception as e: | |
| print(f"--- [CRITICAL ERROR] Full analysis for {ticker} failed. ---") | |
| traceback.print_exc() | |
| print("--- End of Traceback ---") | |
| # Return an empty title and an error DataFrame | |
| return "", pd.DataFrame({"Error": [f"{e}"]}) | |
| # --- 4. GRADIO INTERFACE --- | |
| with gr.Blocks(theme=gr.themes.Soft()) as iface: | |
| gr.Markdown("# Fundamental Analysis Stock Valuator") | |
| gr.Markdown("Enter any US stock ticker (e.g., AAPL, MSFT, GOOGL) and click 'Analyze' to run a DCF and Comps valuation.") | |
| with gr.Row(): | |
| ticker_input = gr.Textbox( | |
| value="AAPL", | |
| label="Enter a Stock Ticker" | |
| ) | |
| analyze_button = gr.Button("Analyze Stock") | |
| # New component to display the company name | |
| company_name_md = gr.Markdown() | |
| output_df = gr.DataFrame(label="Valuation Summary") | |
| analyze_button.click( | |
| fn=analyze_stock, | |
| inputs=ticker_input, | |
| outputs=[company_name_md, output_df] # The function now updates two components | |
| ) | |
| if __name__ == "__main__": | |
| iface.launch() |