""" Financial ratio analysis module for XBRL data. This module provides a comprehensive set of financial ratio calculations for analyzing company performance, efficiency, and financial health using DataFrame operations for handling multiple periods efficiently. """ from dataclasses import dataclass from typing import Callable, Dict, List, Mapping, Optional, Tuple, Union import numpy as np import pandas as pd from pandas import Index from rich import box from rich.console import Group from rich.panel import Panel from rich.table import Table from rich.text import Text from edgar.richtools import repr_rich from edgar.xbrl.standardization import MappingStore, StandardConcept def _clean_series_data(series: pd.Series) -> pd.Series: """ Clean pandas Series data by converting empty strings and invalid values to NaN. Args: series: Input pandas Series that may contain empty strings or invalid values Returns: Cleaned pandas Series with empty strings converted to NaN and proper numeric dtype """ if series is None: return series # Create a copy to avoid modifying the original cleaned = series.copy() # Convert empty strings to NaN if cleaned.dtype == object: # Replace empty strings and whitespace-only strings with NaN cleaned = cleaned.replace(r'^\s*$', np.nan, regex=True).infer_objects(copy=False) cleaned = cleaned.replace('', np.nan).infer_objects(copy=False) # Try to convert to numeric, coercing errors to NaN cleaned = pd.to_numeric(cleaned, errors='coerce') return cleaned def _safe_divide(numerator: pd.Series, denominator: pd.Series) -> pd.Series: """ Safely divide two pandas Series, handling NaN and zero denominators. Args: numerator: Series to be divided denominator: Series to divide by Returns: Series with division results, NaN where division is invalid """ # Clean both series first clean_num = _clean_series_data(numerator) clean_denom = _clean_series_data(denominator) # Use pandas division which handles NaN and division by zero appropriately with np.errstate(divide='ignore', invalid='ignore'): result = clean_num / clean_denom return result def _safe_subtract(minuend: pd.Series, subtrahend: pd.Series) -> pd.Series: """ Safely subtract two pandas Series, handling NaN values. Args: minuend: Series to subtract from subtrahend: Series to subtract Returns: Series with subtraction results, NaN where subtraction is invalid """ # Clean both series first clean_minuend = _clean_series_data(minuend) clean_subtrahend = _clean_series_data(subtrahend) # Use pandas subtraction which handles NaN appropriately result = clean_minuend - clean_subtrahend return result @dataclass class ConceptEquivalent: """Defines an equivalent calculation for a missing concept.""" target_concept: str required_concepts: List[str] calculation: Callable[[pd.DataFrame, str], float] description: str @dataclass class RatioAnalysisGroup: """Container for a group of related ratio analyses. Attributes: name: Name of the ratio group (e.g. 'Profitability Ratios') description: Description of what these ratios measure ratios: Dict mapping ratio names to their RatioAnalysis objects """ name: str description: str ratios: Dict[str, 'RatioAnalysis'] def __rich__(self): headers = [""] + next(iter(self.ratios.values())).results.columns.tolist() table = Table(*headers, box=box.SIMPLE) renderables = [table] for ratio in self.ratios.values(): for record in ratio.results.itertuples(): values = [Text(f"{v:.2f}", justify="right") for v in record[1:]] row = [ratio.name] + values table.add_row(*row) panel = Panel(Group(*renderables), title=self.name, expand=False) return panel def __repr__(self) -> str: return repr_rich(self.__rich__()) @dataclass class RatioData: """Container for financial ratio calculation data. Attributes: calculation_df: DataFrame containing the raw data for calculation periods: List of available reporting periods equivalents_used: Dictionary mapping concepts to their equivalent descriptions required_concepts: List of concepts required for the ratio optional_concepts: Dictionary mapping optional concepts to their default values """ calculation_df: pd.DataFrame periods: List[str] equivalents_used: Dict[str, str] required_concepts: List[str] optional_concepts: Dict[str, float] def has_concept(self, concept: str) -> bool: """Check if a concept is available in the calculation DataFrame. Args: concept: The concept to check Returns: True if the concept exists and has at least one non-NaN, non-empty string value """ if concept not in self.calculation_df.index: return False # Get the raw series and clean it to properly check for valid data raw_series = self.calculation_df.loc[concept] cleaned_series = _clean_series_data(raw_series) # Check if we have at least one valid (non-NaN) value return not cleaned_series.isna().all() and len(cleaned_series.dropna()) > 0 def get_concept(self, concept: str, default_value: Optional[float] = None) -> pd.Series: """Get a concept's values for all periods. Args: concept: The concept to retrieve default_value: Default value to use if concept is not found (only for optional concepts) Returns: Series containing the concept values indexed by period, with empty strings converted to NaN Raises: KeyError: If concept is required but not found and no default is provided """ if self.has_concept(concept): raw_series = self.calculation_df.loc[concept] # Clean the series data to convert empty strings to NaN cleaned_series = _clean_series_data(raw_series) return cleaned_series # Concept not found or all NaN if concept in self.required_concepts and default_value is None: raise KeyError(f"Required concept {concept} not found") # Optional concept with default value or required concept with default override if default_value is not None: # Create Series of default values for all periods return pd.Series(default_value, index=self.periods) # Check if we have a predefined default for this optional concept if concept in self.optional_concepts: return pd.Series(self.optional_concepts[concept], index=self.periods) raise KeyError(f"Concept {concept} not found and no default value provided") def get_concepts(self, concepts: List[str]) -> Dict[str, pd.Series]: """Get multiple concepts at once. Args: concepts: List of concepts to retrieve Returns: Dictionary mapping concepts to their value Series Raises: KeyError: If any required concept is not found """ return {concept: self.get_concept(concept) for concept in concepts} @dataclass class RatioAnalysis: """Container for ratio calculation results with metadata. Attributes: name: Name of the ratio description: Description of what the ratio measures calculation_df: DataFrame containing the raw data used in calculation results: Series containing the calculated ratio values components: Dict mapping component names to their value Series equivalents_used: Dict mapping concepts to their equivalent descriptions """ name: str description: str calculation_df: pd.DataFrame results: pd.Series components: Dict[str, pd.Series] equivalents_used: Mapping[str, str] def __rich__(self): headers = [""] + self.results.columns.tolist() table = Table(*headers, box=box.SIMPLE) renderables = [table] for record in self.results.itertuples(): values = [Text(f"{v:.2f}", justify="right") for v in record[1:]] row = [self.name] + values table.add_row(*row) panel = Panel(Group(*renderables), title=self.name, expand=False) return panel def __repr__(self) -> str: return repr_rich(self.__rich__()) class FinancialRatios: """Calculate and analyze financial ratios from XBRL data using DataFrame operations.""" def __init__(self, xbrl): """Initialize with an XBRL instance. Args: xbrl: XBRL instance containing financial statements """ self.xbrl = xbrl # Initialize concept mappings and equivalents self._mapping_store = MappingStore() self._concept_equivalents = self._initialize_concept_equivalents() # Get rendered statements bs = self.xbrl.statements.balance_sheet() is_ = self.xbrl.statements.income_statement() cf = self.xbrl.statements.cashflow_statement() # Convert to DataFrames with consistent periods bs_rendered = bs.render() is_rendered = is_.render() cf_rendered = cf.render() self.balance_sheet_df = bs_rendered.to_dataframe() self.income_stmt_df = is_rendered.to_dataframe() self.cash_flow_df = cf_rendered.to_dataframe() # Get all unique periods across statements self.periods = sorted(set( str(p.end_date) for p in bs_rendered.periods + is_rendered.periods + cf_rendered.periods )) def _prepare_ratio_df(self, required_concepts: List[str], statement_dfs: List[Tuple[pd.DataFrame, str]], optional_concepts: List[str] = None) -> Tuple[pd.DataFrame, Dict[str, str]]: """Prepare DataFrame for ratio calculations. Args: required_concepts: List of concepts required for the ratio calculation statement_dfs: List of tuples containing statement DataFrames and their types optional_concepts: List of concepts that are optional for the calculation Returns: Tuple containing: - DataFrame with required concepts as columns - Dictionary mapping concepts to their equivalent descriptions (non-None values only) """ """Prepare a DataFrame for ratio calculation. Args: required_concepts: List of concepts required for the ratio statement_dfs: List of (DataFrame, statement_type) tuples to search for concepts Returns: Tuple containing: - DataFrame with concepts as index and periods as columns - Dictionary mapping concepts to their equivalent descriptions if used """ # Get the set of periods available in each statement if optional_concepts is None: optional_concepts = [] available_periods = set() for df, _ in statement_dfs: # Get columns that are periods (exclude 'concept', 'label', etc) period_cols = [col for col in df.columns if col in self.periods] if not available_periods: available_periods = set(period_cols) else: available_periods &= set(period_cols) if not available_periods: raise ValueError("No common periods found across required statements") all_concepts = required_concepts + optional_concepts # Create empty DataFrame with only the common periods calc_df = pd.DataFrame(index=pd.Index(all_concepts), columns=Index(sorted(available_periods))) # Track which concepts used equivalents equivalents_used = {} # Fill values from each statement for concept in all_concepts: found = False # First try to find matching company concepts from the mapping store if concept in self._mapping_store.mappings: company_concepts = self._mapping_store.mappings[concept] for df, _statement_type in statement_dfs: # Check each possible company concept for company_concept in company_concepts: mask = df['concept'] == company_concept if mask.any(): matching_row = df[mask].iloc[0] # Only copy values for available periods calc_df.loc[concept] = matching_row[calc_df.columns] found = True break if found: break # If not found via mappings, try direct concept match if not found: for df, _statement_type in statement_dfs: mask = df['concept'] == concept if mask.any(): matching_row = df[mask].iloc[0] # Only copy values for available periods calc_df.loc[concept] = matching_row[calc_df.columns] found = True break # If still not found, try matching by label if not found: for df, _statement_type in statement_dfs: # Get label column if it exists if 'label' in df.columns: mask = df['label'].str.contains(concept, case=False, na=False) if mask.any(): matching_row = df[mask].iloc[0] # Only copy values for available periods calc_df.loc[concept] = matching_row[calc_df.columns] found = True break # If still not found or all NaN, try concept equivalents if not found or calc_df.loc[concept].isna().all(): if concept in self._concept_equivalents: for equivalent in self._concept_equivalents[concept]: try: # Recursively prepare data for required concepts sub_df, sub_equiv = self._prepare_ratio_df( equivalent.required_concepts, statement_dfs) # Calculate equivalent value for each period for period in calc_df.columns: calc_df.loc[concept, period] = equivalent.calculation(sub_df, period) # Track that we used this equivalent equivalents_used[concept] = equivalent.description # Also include any equivalents used by subconcepts equivalents_used.update(sub_equiv) found = True break except (KeyError, ValueError, ZeroDivisionError): continue if not found and concept not in optional_concepts: raise KeyError(f"Could not find or calculate required concept: {concept}") # Filter out None values from equivalents and ensure all values are strings filtered_equivalents = {k: str(v) for k, v in equivalents_used.items() if v is not None} # Return the prepared DataFrame and filtered equivalents return calc_df, filtered_equivalents def _initialize_concept_equivalents(self) -> Dict[str, List[ConceptEquivalent]]: """Initialize the concept equivalents mapping. Enhanced to work with the new standardization hierarchy that separates different revenue and cost types into distinct concepts. Returns: Dictionary mapping concepts to their possible equivalent calculations. """ return { # Gross Profit calculation with enhanced cost fallbacks StandardConcept.GROSS_PROFIT: [ ConceptEquivalent( target_concept=StandardConcept.GROSS_PROFIT, required_concepts=[ StandardConcept.REVENUE, StandardConcept.COST_OF_REVENUE ], calculation=lambda df, period: ( df.loc[StandardConcept.REVENUE, period] - df.loc[StandardConcept.COST_OF_REVENUE, period] ), description="Revenue - Cost of Revenue" ), # Fallback for manufacturing companies using Cost of Goods Sold ConceptEquivalent( target_concept=StandardConcept.GROSS_PROFIT, required_concepts=[ StandardConcept.REVENUE, StandardConcept.COST_OF_GOODS_SOLD ], calculation=lambda df, period: ( df.loc[StandardConcept.REVENUE, period] - df.loc[StandardConcept.COST_OF_GOODS_SOLD, period] ), description="Revenue - Cost of Goods Sold (manufacturing)" ), # Fallback for retail companies using Cost of Sales ConceptEquivalent( target_concept=StandardConcept.GROSS_PROFIT, required_concepts=[ StandardConcept.REVENUE, StandardConcept.COST_OF_SALES ], calculation=lambda df, period: ( df.loc[StandardConcept.REVENUE, period] - df.loc[StandardConcept.COST_OF_SALES, period] ), description="Revenue - Cost of Sales (retail)" ), # Fallback for some telecom or service companies using Cost of Goods and Services Sold ConceptEquivalent( target_concept=StandardConcept.GROSS_PROFIT, required_concepts=[ StandardConcept.REVENUE, StandardConcept.COSTS_AND_EXPENSES ], calculation=lambda df, period: ( df.loc[StandardConcept.REVENUE, period] - df.loc[StandardConcept.COSTS_AND_EXPENSES, period] ), description="Revenue - Cost and Expenses (telecom/service companies)" ) ], # Operating Income calculation StandardConcept.OPERATING_INCOME: [ ConceptEquivalent( target_concept=StandardConcept.OPERATING_INCOME, required_concepts=[ StandardConcept.GROSS_PROFIT, StandardConcept.OPERATING_EXPENSES ], calculation=lambda df, period: ( df.loc[StandardConcept.GROSS_PROFIT, period] - df.loc[StandardConcept.OPERATING_EXPENSES, period] ), description="Gross Profit - Operating Expenses" ) ], # Revenue equivalents with enhanced hierarchy support StandardConcept.REVENUE: [ # Mixed companies: Product + Service revenue ConceptEquivalent( target_concept=StandardConcept.REVENUE, required_concepts=[ StandardConcept.PRODUCT_REVENUE, StandardConcept.SERVICE_REVENUE ], calculation=lambda df, period: ( df.loc[StandardConcept.PRODUCT_REVENUE, period] + df.loc[StandardConcept.SERVICE_REVENUE, period] ), description="Product Revenue + Service Revenue (mixed companies)" ), # Service companies: Contract revenue fallback ConceptEquivalent( target_concept=StandardConcept.REVENUE, required_concepts=[StandardConcept.CONTRACT_REVENUE], calculation=lambda df, period: df.loc[StandardConcept.CONTRACT_REVENUE, period], description="Contract Revenue (service companies)" ), # Product companies: Product revenue fallback ConceptEquivalent( target_concept=StandardConcept.REVENUE, required_concepts=[StandardConcept.PRODUCT_REVENUE], calculation=lambda df, period: df.loc[StandardConcept.PRODUCT_REVENUE, period], description="Product Revenue (manufacturing companies)" ) ], # Cost of Revenue fallbacks for different business models StandardConcept.COST_OF_REVENUE: [ # Manufacturing companies fallback ConceptEquivalent( target_concept=StandardConcept.COST_OF_REVENUE, required_concepts=[StandardConcept.COST_OF_GOODS_SOLD], calculation=lambda df, period: df.loc[StandardConcept.COST_OF_GOODS_SOLD, period], description="Cost of Goods Sold (manufacturing companies)" ), # Retail companies fallback ConceptEquivalent( target_concept=StandardConcept.COST_OF_REVENUE, required_concepts=[StandardConcept.COST_OF_SALES], calculation=lambda df, period: df.loc[StandardConcept.COST_OF_SALES, period], description="Cost of Sales (retail companies)" ), # Mixed companies fallback ConceptEquivalent( target_concept=StandardConcept.COST_OF_REVENUE, required_concepts=[StandardConcept.COST_OF_GOODS_AND_SERVICES_SOLD], calculation=lambda df, period: df.loc[StandardConcept.COST_OF_GOODS_AND_SERVICES_SOLD, period], description="Cost of Goods and Services Sold (mixed companies)" ) ] } def _get_concept_value(self, concept: str, calc_df: pd.DataFrame) -> Tuple[pd.Series, Optional[str]]: """Get a concept value from the calculation DataFrame. If the concept is not directly available or is all NaN, try to calculate it using equivalents. Args: concept: The concept to retrieve calc_df: DataFrame containing the raw data Returns: Tuple of (value Series, equivalent description if used) Raises: KeyError: If concept is not found and no valid equivalents are available """ # First try to get the direct value try: raw_value = calc_df.loc[concept] # Clean the value to convert empty strings to NaN value = _clean_series_data(raw_value) # Check if we actually have any non-NaN values if not value.isna().all(): return value, None except KeyError: pass # If we get here, either the concept wasn't found or was all NaN # Try to use concept equivalents if concept in self._concept_equivalents: for equivalent in self._concept_equivalents[concept]: try: # Check if all required concepts are available and have values for req in equivalent.required_concepts: if req not in calc_df.index: continue # Calculate equivalent value for each period values = pd.Series(index=calc_df.columns, dtype=float) for period in calc_df.columns: try: # Clean the calc_df data before calculation calc_value = equivalent.calculation(calc_df, period) values[period] = calc_value if pd.notna(calc_value) else np.nan except (TypeError, ValueError): # If calculation fails due to string/numeric operations, set to NaN values[period] = np.nan return values, equivalent.description except (KeyError, ZeroDivisionError): continue # If we get here, no valid concept or equivalent was found raise KeyError(f"Concept {concept} not found and no valid equivalents available") def get_ratio_data(self, ratio_type: str) -> RatioData: """Get the prepared ratio data for a specific ratio calculation. This allows inspection of the raw data before ratio calculation. Args: ratio_type: Type of ratio to get data for ('current', 'operating_margin', 'return_on_assets', 'gross_margin', 'leverage') Returns: RatioData object containing calculation data and helper methods for accessing concepts """ # Default values for optional concepts (used when concept is not found) default_values = { StandardConcept.INVENTORY: 0.0, # For quick ratio when inventory not found } ratio_configs = { 'current': { 'concepts': [ StandardConcept.TOTAL_CURRENT_ASSETS, StandardConcept.TOTAL_CURRENT_LIABILITIES, StandardConcept.CASH_AND_EQUIVALENTS # For cash ratio ], 'optional_concepts': { StandardConcept.INVENTORY: 0.0 # Optional for quick ratio }, 'statements': [(self.balance_sheet_df, "BalanceSheet")] }, 'operating_margin': { 'concepts': [ StandardConcept.OPERATING_INCOME, StandardConcept.REVENUE ], 'optional_concepts': {}, 'statements': [(self.income_stmt_df, "IncomeStatement")] }, 'return_on_assets': { 'concepts': [ StandardConcept.NET_INCOME, StandardConcept.TOTAL_ASSETS ], 'optional_concepts': {}, 'statements': [ (self.income_stmt_df, "IncomeStatement"), (self.balance_sheet_df, "BalanceSheet") ] }, 'gross_margin': { 'concepts': [ StandardConcept.GROSS_PROFIT, StandardConcept.REVENUE ], 'optional_concepts': {}, 'statements': [(self.income_stmt_df, "IncomeStatement")] }, 'leverage': { 'concepts': [ StandardConcept.LONG_TERM_DEBT, StandardConcept.TOTAL_EQUITY, StandardConcept.TOTAL_ASSETS, StandardConcept.OPERATING_INCOME, StandardConcept.INTEREST_EXPENSE ], 'optional_concepts': {}, 'statements': [ (self.balance_sheet_df, "BalanceSheet"), (self.income_stmt_df, "IncomeStatement") ] } } if ratio_type not in ratio_configs: raise ValueError(f"Unknown ratio type: {ratio_type}. Valid types are: {list(ratio_configs.keys())}") config = ratio_configs[ratio_type] # Convert optional_concepts from list to dict if it's still in the old format optional_concepts_dict = config.get('optional_concepts', {}) if isinstance(optional_concepts_dict, list): # Convert list to dict using default values optional_concepts_dict = { concept: default_values.get(concept, 0.0) for concept in optional_concepts_dict } # Get the concepts and equivalents using the old method calc_df, equivalents = self._prepare_ratio_df( required_concepts=config['concepts'], statement_dfs=config['statements'], optional_concepts=list(optional_concepts_dict.keys()) ) # Create and return the RatioData object return RatioData( calculation_df=calc_df, periods=calc_df.columns.tolist(), equivalents_used=equivalents, required_concepts=config['concepts'], optional_concepts=optional_concepts_dict ) def calculate_current_ratio(self) -> RatioAnalysis: """Calculate current ratio for all periods. Current Ratio = Current Assets / Current Liabilities """ ratio_data = self.get_ratio_data('current') try: # Get required concepts directly from RatioData current_assets = ratio_data.get_concept(StandardConcept.TOTAL_CURRENT_ASSETS) current_liab = ratio_data.get_concept(StandardConcept.TOTAL_CURRENT_LIABILITIES) # Collect equivalent descriptions if any were used equivalents_used = {} if StandardConcept.TOTAL_CURRENT_ASSETS in ratio_data.equivalents_used: equivalents_used['current_assets'] = ratio_data.equivalents_used[StandardConcept.TOTAL_CURRENT_ASSETS] if StandardConcept.TOTAL_CURRENT_LIABILITIES in ratio_data.equivalents_used: equivalents_used['current_liabilities'] = ratio_data.equivalents_used[StandardConcept.TOTAL_CURRENT_LIABILITIES] return RatioAnalysis( name="Current Ratio", description="Measures ability to pay short-term obligations", calculation_df=ratio_data.calculation_df, results=_safe_divide(current_assets, current_liab).to_frame().T, components={ 'current_assets': current_assets, 'current_liabilities': current_liab }, equivalents_used={k: str(v) for k, v in equivalents_used.items() if v is not None} ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate current ratio: {str(e)}") from e def calculate_return_on_assets(self) -> RatioAnalysis: """Calculate return on assets for all periods. ROA = Net Income / Average Total Assets """ calc_df, equivalents = self.get_ratio_data('return_on_assets') try: net_income, income_equiv = self._get_concept_value( StandardConcept.NET_INCOME, calc_df) total_assets, assets_equiv = self._get_concept_value( StandardConcept.TOTAL_ASSETS, calc_df) # Calculate average total assets using shift prev_assets = total_assets.shift(1) avg_assets = (total_assets + prev_assets.fillna(total_assets)) / 2 equivalents_used = {} if income_equiv: equivalents_used['net_income'] = income_equiv if assets_equiv: equivalents_used['total_assets'] = assets_equiv return RatioAnalysis( name="Return on Assets", description="Measures how efficiently company uses its assets to generate earnings", calculation_df=calc_df, results=_safe_divide(net_income, avg_assets).to_frame().T, components={ 'net_income': net_income, 'average_total_assets': avg_assets }, equivalents_used={k: str(v) for k, v in (equivalents_used or {}).items() if v is not None} ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate return on assets: {str(e)}") from e def calculate_operating_margin(self) -> RatioAnalysis: """Calculate operating margin for all periods. Operating Margin = Operating Income / Revenue """ calc_df, equivalents = self.get_ratio_data('operating_margin') try: operating_income, income_equiv = self._get_concept_value( StandardConcept.OPERATING_INCOME, calc_df) revenue, revenue_equiv = self._get_concept_value( StandardConcept.REVENUE, calc_df) equivalents_used = {} if income_equiv: equivalents_used['operating_income'] = income_equiv if revenue_equiv: equivalents_used['revenue'] = revenue_equiv return RatioAnalysis( name="Operating Margin", description="Measures operating efficiency and pricing strategy", calculation_df=calc_df, results=_safe_divide(operating_income, revenue).to_frame().T, components={ 'operating_income': operating_income, 'revenue': revenue }, equivalents_used={k: str(v) for k, v in (equivalents_used or {}).items() if v is not None} ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate operating margin: {str(e)}") from e def calculate_gross_margin(self) -> RatioAnalysis: """Calculate gross margin for all periods. Gross Margin = Gross Profit / Revenue Note: If Gross Profit is not directly available, it will be calculated as Revenue - Cost of Revenue. """ calc_df, equivalents = self.get_ratio_data('gross_margin') try: gross_profit, profit_equiv = self._get_concept_value( StandardConcept.GROSS_PROFIT, calc_df) revenue, revenue_equiv = self._get_concept_value( StandardConcept.REVENUE, calc_df) equivalents_used = {} if profit_equiv: equivalents_used['gross_profit'] = profit_equiv if revenue_equiv: equivalents_used['revenue'] = revenue_equiv return RatioAnalysis( name="Gross Margin", description="Measures basic profitability from core business activities", calculation_df=calc_df, results=_safe_divide(gross_profit, revenue).to_frame().T, components={ 'gross_profit': gross_profit, 'revenue': revenue }, equivalents_used={k: str(v) for k, v in (equivalents_used or {}).items() if v is not None} ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate gross margin: {str(e)}") from e def calculate_quick_ratio(self) -> RatioAnalysis: """Calculate quick ratio for all periods. Quick Ratio = (Current Assets - Inventory) / Current Liabilities Also known as the Acid Test Ratio. Note: If inventory is not found in the financial statements, it will be treated as 0. This is appropriate for service companies or companies that do not carry inventory. In such cases, the quick ratio will equal the current ratio. """ ratio_data = self.get_ratio_data('current') try: # Get concepts with defaults handling current_assets = ratio_data.get_concept(StandardConcept.TOTAL_CURRENT_ASSETS) current_liab = ratio_data.get_concept(StandardConcept.TOTAL_CURRENT_LIABILITIES) # Get inventory with default 0 - the RatioData class handles missing inventory inventory = ratio_data.get_concept(StandardConcept.INVENTORY) # Calculate quick assets using safe subtraction quick_assets = _safe_subtract(current_assets, inventory) # Collect equivalent descriptions for used concepts equivalents_used = {} if StandardConcept.TOTAL_CURRENT_ASSETS in ratio_data.equivalents_used: equivalents_used['current_assets'] = ratio_data.equivalents_used[StandardConcept.TOTAL_CURRENT_ASSETS] if StandardConcept.TOTAL_CURRENT_LIABILITIES in ratio_data.equivalents_used: equivalents_used['current_liabilities'] = ratio_data.equivalents_used[StandardConcept.TOTAL_CURRENT_LIABILITIES] if StandardConcept.INVENTORY in ratio_data.equivalents_used: equivalents_used['inventory'] = ratio_data.equivalents_used[StandardConcept.INVENTORY] elif not ratio_data.has_concept(StandardConcept.INVENTORY): # If inventory was using the default and wasn't in equivalents equivalents_used['inventory'] = "Treated as 0 (not found in statements)" return RatioAnalysis( name="Quick Ratio", description="Measures ability to pay short-term obligations using only highly liquid assets", calculation_df=ratio_data.calculation_df, results=_safe_divide(quick_assets, current_liab).to_frame().T, components={ 'quick_assets': quick_assets, 'current_liabilities': current_liab, 'inventory': inventory }, equivalents_used={k: str(v) for k, v in equivalents_used.items() if v is not None} ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate quick ratio: {str(e)}") from e def calculate_cash_ratio(self) -> RatioAnalysis: """Calculate cash ratio for all periods. Cash Ratio = Cash / Current Liabilities Measures ability to pay short-term obligations using only cash. """ calc_df, equivalents = self.get_ratio_data('current') try: cash, cash_equiv = self._get_concept_value( StandardConcept.CASH_AND_EQUIVALENTS, calc_df) current_liab, liab_equiv = self._get_concept_value( StandardConcept.TOTAL_CURRENT_LIABILITIES, calc_df) equivalents_used = {} if cash_equiv: equivalents_used['cash'] = cash_equiv if liab_equiv: equivalents_used['current_liabilities'] = liab_equiv return RatioAnalysis( name="Cash Ratio", description="Measures ability to pay short-term obligations using only cash", calculation_df=calc_df, results=_safe_divide(cash, current_liab).to_frame().T, components={ 'cash': cash, 'current_liabilities': current_liab }, equivalents_used={k: str(v) for k, v in (equivalents_used or {}).items() if v is not None} ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate cash ratio: {str(e)}") from e def calculate_working_capital(self) -> RatioAnalysis: """Calculate working capital for all periods. Working Capital = Current Assets - Current Liabilities Measures short-term financial health. """ calc_df, equivalents = self.get_ratio_data('current') try: current_assets, assets_equiv = self._get_concept_value( StandardConcept.TOTAL_CURRENT_ASSETS, calc_df) current_liab, liab_equiv = self._get_concept_value( StandardConcept.TOTAL_CURRENT_LIABILITIES, calc_df) equivalents_used = {} if assets_equiv: equivalents_used['current_assets'] = assets_equiv if liab_equiv: equivalents_used['current_liabilities'] = liab_equiv working_capital = _safe_subtract(current_assets, current_liab) return RatioAnalysis( name="Working Capital", description="Measures short-term financial health", calculation_df=calc_df, results=working_capital.to_frame().T, components={ 'current_assets': current_assets, 'current_liabilities': current_liab }, equivalents_used={k: str(v) for k, v in (equivalents_used or {}).items() if v is not None} ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate working capital: {str(e)}") from e def calculate_profitability_ratios(self) -> RatioAnalysisGroup: """Calculate profitability ratios. Returns: RatioAnalysisGroup containing: - gross_margin - operating_margin - net_margin - return_on_assets - return_on_equity """ calc_df, equivalents = self.get_ratio_data('profitability') try: revenue, revenue_equiv = self._get_concept_value(StandardConcept.REVENUE, calc_df) gross_profit, profit_equiv = self._get_concept_value(StandardConcept.GROSS_PROFIT, calc_df) operating_income, income_equiv = self._get_concept_value(StandardConcept.OPERATING_INCOME, calc_df) net_income, net_equiv = self._get_concept_value(StandardConcept.NET_INCOME, calc_df) total_assets, assets_equiv = self._get_concept_value(StandardConcept.TOTAL_ASSETS, calc_df) total_equity, equity_equiv = self._get_concept_value(StandardConcept.TOTAL_EQUITY, calc_df) results = {} # Margin Ratios if gross_profit is not None: results['gross_margin'] = RatioAnalysis( name="Gross Margin", description="Measures basic profitability from core business activities", calculation_df=calc_df, results=(gross_profit / revenue).to_frame().T, components={ 'gross_profit': gross_profit, 'revenue': revenue }, equivalents_used={k: str(v) for k, v in {'gross_profit': profit_equiv}.items() if v is not None} ) if operating_income is not None: results['operating_margin'] = RatioAnalysis( name="Operating Margin", description="Measures operating efficiency and pricing strategy", calculation_df=calc_df, results=(operating_income / revenue).to_frame().T, components={ 'operating_income': operating_income, 'revenue': revenue }, equivalents_used={k: str(v) for k, v in {'operating_income': income_equiv}.items() if v is not None} ) if net_income is not None: results['net_margin'] = RatioAnalysis( name="Net Margin", description="Measures overall profitability after all expenses", calculation_df=calc_df, results=(net_income / revenue).to_frame().T, components={ 'net_income': net_income, 'revenue': revenue }, equivalents_used={k: str(v) for k, v in {'net_income': net_equiv}.items() if v is not None} ) # Return on Assets if total_assets is not None: results['return_on_assets'] = RatioAnalysis( name="Return on Assets", description="Measures how efficiently company uses its assets to generate earnings", calculation_df=calc_df, results=(net_income / total_assets).to_frame().T, components={ 'net_income': net_income, 'total_assets': total_assets }, equivalents_used={k: str(v) for k, v in { 'net_income': net_equiv, 'total_assets': assets_equiv }.items() if v is not None} ) # Return on Equity if total_equity is not None: results['return_on_equity'] = RatioAnalysis( name="Return on Equity", description="Measures return on shareholder investment", calculation_df=calc_df, results=(net_income / total_equity).to_frame().T, components={ 'net_income': net_income, 'total_equity': total_equity }, equivalents_used={k: str(v) for k, v in { 'net_income': net_equiv, 'total_equity': equity_equiv }.items() if v is not None} ) return RatioAnalysisGroup( name="Profitability Ratios", description="Measures of company's ability to generate profits and returns", ratios=results ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate profitability ratios: {str(e)}") from e def calculate_efficiency_ratios(self) -> RatioAnalysisGroup: """Calculate efficiency ratios. Returns: RatioAnalysisGroup containing: - asset_turnover - inventory_turnover - receivables_turnover - days_sales_outstanding """ calc_df, equivalents = self.get_ratio_data('efficiency') try: revenue, revenue_equiv = self._get_concept_value(StandardConcept.REVENUE, calc_df) total_assets, assets_equiv = self._get_concept_value(StandardConcept.TOTAL_ASSETS, calc_df) inventory, inventory_equiv = self._get_concept_value(StandardConcept.INVENTORY, calc_df) cogs, cogs_equiv = self._get_concept_value(StandardConcept.COST_OF_REVENUE, calc_df) receivables, receivables_equiv = self._get_concept_value(StandardConcept.ACCOUNTS_RECEIVABLE, calc_df) results = {} # Asset Turnover if total_assets is not None: results['asset_turnover'] = RatioAnalysis( name="Asset Turnover", description="Measures how efficiently company uses its assets to generate revenue", calculation_df=calc_df, results=(revenue / total_assets).to_frame().T, components={ 'revenue': revenue, 'total_assets': total_assets }, equivalents_used={k: str(v) for k, v in { 'revenue': revenue_equiv, 'total_assets': assets_equiv }.items() if v is not None} ) # Inventory Turnover if inventory is not None and cogs is not None: results['inventory_turnover'] = RatioAnalysis( name="Inventory Turnover", description="Measures how quickly inventory is sold and replaced", calculation_df=calc_df, results=(cogs / inventory).to_frame().T, components={ 'cogs': cogs, 'inventory': inventory }, equivalents_used={k: str(v) for k, v in { 'cogs': cogs_equiv, 'inventory': inventory_equiv }.items() if v is not None} ) # Receivables Turnover if receivables is not None: turnover = revenue / receivables results['receivables_turnover'] = RatioAnalysis( name="Receivables Turnover", description="Measures how quickly company collects receivables", calculation_df=calc_df, results=turnover, components={ 'revenue': revenue, 'receivables': receivables }, equivalents_used={k: str(v) for k, v in { 'revenue': revenue_equiv, 'receivables': receivables_equiv }.items() if v is not None} ) # Days Sales Outstanding results['days_sales_outstanding'] = RatioAnalysis( name="Days Sales Outstanding", description="Average number of days to collect payment", calculation_df=calc_df, results=(365 / turnover).to_frame().T, components={ 'receivables_turnover': turnover }, equivalents_used={} ) return RatioAnalysisGroup( name="Efficiency Ratios", description="Measures of company's operational efficiency", ratios=results ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate efficiency ratios: {str(e)}") from e def calculate_leverage_ratios(self) -> RatioAnalysisGroup: """Calculate leverage ratios. Returns: RatioAnalysisGroup containing: - debt_to_equity - debt_to_assets - interest_coverage - equity_multiplier """ calc_df, equivalents = self.get_ratio_data('leverage') try: total_debt, debt_equiv = self._get_concept_value(StandardConcept.LONG_TERM_DEBT, calc_df) total_equity, equity_equiv = self._get_concept_value(StandardConcept.TOTAL_EQUITY, calc_df) total_assets, assets_equiv = self._get_concept_value(StandardConcept.TOTAL_ASSETS, calc_df) operating_income, income_equiv = self._get_concept_value(StandardConcept.OPERATING_INCOME, calc_df) interest_expense, interest_equiv = self._get_concept_value(StandardConcept.INTEREST_EXPENSE, calc_df) results = {} # Debt to Equity if total_debt is not None and total_equity is not None: results['debt_to_equity'] = RatioAnalysis( name="Debt to Equity", description="Measures financial leverage and long-term solvency", calculation_df=calc_df, results=(total_debt / total_equity).to_frame().T, components={ 'total_debt': total_debt, 'total_equity': total_equity }, equivalents_used={k: str(v) for k, v in { 'total_debt': debt_equiv, 'total_equity': equity_equiv }.items() if v is not None} ) # Debt to Assets if total_debt is not None and total_assets is not None: results['debt_to_assets'] = RatioAnalysis( name="Debt to Assets", description="Measures what percentage of assets are financed by debt", calculation_df=calc_df, results=(total_debt / total_assets).to_frame().T, components={ 'total_debt': total_debt, 'total_assets': total_assets }, equivalents_used={k: str(v) for k, v in { 'total_debt': debt_equiv, 'total_assets': assets_equiv }.items() if v is not None} ) # Interest Coverage if operating_income is not None and interest_expense is not None: results['interest_coverage'] = RatioAnalysis( name="Interest Coverage", description="Measures ability to meet interest payments", calculation_df=calc_df, results=(operating_income / interest_expense).to_frame().T, components={ 'operating_income': operating_income, 'interest_expense': interest_expense }, equivalents_used={k: str(v) for k, v in { 'operating_income': income_equiv, 'interest_expense': interest_equiv }.items() if v is not None} ) # Equity Multiplier if total_assets is not None and total_equity is not None: results['equity_multiplier'] = RatioAnalysis( name="Equity Multiplier", description="Measures financial leverage by assets to equity ratio", calculation_df=calc_df, results=(total_assets / total_equity).to_frame().T, components={ 'total_assets': total_assets, 'total_equity': total_equity }, equivalents_used={k: str(v) for k, v in { 'total_assets': assets_equiv, 'total_equity': equity_equiv }.items() if v is not None} ) return RatioAnalysisGroup( name="Leverage Ratios", description="Measures of company's financial leverage and solvency", ratios=results ) except (KeyError, ZeroDivisionError) as e: raise ValueError(f"Failed to calculate leverage ratios: {str(e)}") from e def calculate_liquidity_ratios(self) -> RatioAnalysisGroup: """Calculate all liquidity ratios. Returns: RatioAnalysisGroup containing all liquidity ratios """ try: ratios = {} ratios['current'] = self.calculate_current_ratio() ratios['quick'] = self.calculate_quick_ratio() ratios['cash'] = self.calculate_cash_ratio() ratios['working_capital'] = self.calculate_working_capital() return RatioAnalysisGroup( name="Liquidity Ratios", description="Measures of a company's ability to pay short-term obligations", ratios=ratios ) except ValueError as e: raise ValueError(f"Failed to calculate liquidity ratios: {str(e)}") from e def calculate_all(self) -> Dict[str, Union[Dict[str, RatioAnalysis], RatioAnalysisGroup]]: """Calculate all available financial ratios.""" try: return { 'liquidity': self.calculate_liquidity_ratios(), 'profitability': self.calculate_profitability_ratios(), 'efficiency': self.calculate_efficiency_ratios(), 'leverage': self.calculate_leverage_ratios() } except ValueError as e: raise ValueError(f"Failed to calculate all ratios: {str(e)}") from e