🇪🇸 Leer en Español 🇺🇸 English
Fundamental Analysis for Quantitative Trading
Introduction
Fundamental analysis evaluates a company’s intrinsic value based on its financial statements, competitive position, and future prospects. For quantitative trading, we automate these analyses to identify value investing opportunities and integrate them with technical strategies.
Core Concepts
Why Does Fundamental Analysis Work?
Intrinsic Value Theory:
- Every company has a real value based on its fundamentals
- Market prices fluctuate around intrinsic value
- Discrepancies create investment opportunities
- Over the long term, price converges toward intrinsic value
Advantages for Small Caps:
- Less analyst coverage = more inefficiencies
- Higher volatility = larger price/value discrepancies
- Information less processed by the market
- Opportunities before they are discovered
Key Metrics
Profitability:
- ROE (Return on Equity)
- ROA (Return on Assets)
- ROC (Return on Capital)
- Profit Margins
Valuation:
- P/E Ratio (Price to Earnings)
- P/B Ratio (Price to Book)
- P/S Ratio (Price to Sales)
- EV/EBITDA
Efficiency:
- Asset Turnover
- Inventory Turnover
- Working Capital Management
Intrinsic Value - DCF Model
The Discounted Cash Flow (DCF) is the most rigorous method for calculating a company’s intrinsic value.
Complete Implementation
import yfinance as yf
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import warnings
warnings.filterwarnings('ignore')
class DCFValuator:
"""
Intrinsic Value Calculator using DCF model
"""
def __init__(self, ticker):
"""
Parameters
----------
ticker : str
Stock symbol to evaluate
"""
self.ticker = ticker
self.stock = yf.Ticker(ticker)
self.financial_data = {}
self.dcf_assumptions = {}
def get_financial_data(self):
"""
Get financial data needed for DCF
"""
try:
# Financial statements
self.cashflow = self.stock.cashflow
self.financials = self.stock.financials
self.balance_sheet = self.stock.balance_sheet
self.info = self.stock.info
# Key data
self.financial_data = {
'free_cash_flow': self.cashflow.loc["Free Cash Flow"].iloc[0],
'total_debt': self.balance_sheet.loc["Long Term Debt"].iloc[0] if "Long Term Debt" in self.balance_sheet.index else 0,
'cash_and_equivalents': self.balance_sheet.loc["Cash And Cash Equivalents"].iloc[0] if "Cash And Cash Equivalents" in self.balance_sheet.index else 0,
'shares_outstanding': self.info.get("sharesOutstanding", self.info.get("impliedSharesOutstanding", 0)),
'current_price': self.info.get("currentPrice", 0),
'market_cap': self.info.get("marketCap", 0),
'revenue': self.financials.loc["Total Revenue"].iloc[0] if "Total Revenue" in self.financials.index else 0,
'net_income': self.financials.loc["Net Income"].iloc[0] if "Net Income" in self.financials.index else 0
}
return True
except Exception as e:
print(f"Error getting financial data for {self.ticker}: {e}")
return False
def calculate_growth_rates(self, years=5):
"""
Calculate historical growth rates
"""
try:
# Historical FCF growth
fcf_historical = self.cashflow.loc["Free Cash Flow"]
if len(fcf_historical) >= 2:
fcf_growth = (fcf_historical.iloc[0] / fcf_historical.iloc[-1]) ** (1/len(fcf_historical)) - 1
else:
fcf_growth = 0.05 # Default 5%
# Historical revenue growth
revenue_historical = self.financials.loc["Total Revenue"]
if len(revenue_historical) >= 2:
revenue_growth = (revenue_historical.iloc[0] / revenue_historical.iloc[-1]) ** (1/len(revenue_historical)) - 1
else:
revenue_growth = 0.03 # Default 3%
return {
'fcf_growth': max(min(fcf_growth, 0.15), -0.05), # Cap between -5% and 15%
'revenue_growth': max(min(revenue_growth, 0.12), -0.02) # Cap between -2% and 12%
}
except Exception as e:
print(f"Error calculating growth rates: {e}")
return {'fcf_growth': 0.05, 'revenue_growth': 0.03}
def set_dcf_assumptions(self, growth_rate=None, discount_rate=0.10,
terminal_growth_rate=0.025, projection_years=5):
"""
Set assumptions for the DCF model
Parameters
----------
growth_rate : float
Annual FCF growth rate (if None, calculated automatically)
discount_rate : float
Discount rate (estimated WACC)
terminal_growth_rate : float
Perpetual growth rate
projection_years : int
Explicit projection years
"""
if growth_rate is None:
growth_rates = self.calculate_growth_rates()
growth_rate = growth_rates['fcf_growth']
self.dcf_assumptions = {
'growth_rate': growth_rate,
'discount_rate': discount_rate,
'terminal_growth_rate': terminal_growth_rate,
'projection_years': projection_years
}
return self.dcf_assumptions
def calculate_dcf_valuation(self):
"""
Calculate complete DCF valuation
Returns
-------
dict
DCF valuation results
"""
if not self.financial_data:
if not self.get_financial_data():
return {'error': 'Could not get financial data'}
if not self.dcf_assumptions:
self.set_dcf_assumptions()
# Extract data
fcf_base = self.financial_data['free_cash_flow']
if fcf_base <= 0:
return {'error': 'Free Cash Flow negativo o cero'}
growth_rate = self.dcf_assumptions['growth_rate']
discount_rate = self.dcf_assumptions['discount_rate']
terminal_growth = self.dcf_assumptions['terminal_growth_rate']
years = self.dcf_assumptions['projection_years']
# Project future FCF
projected_fcf = []
for year in range(1, years + 1):
fcf_year = fcf_base * ((1 + growth_rate) ** year)
projected_fcf.append(fcf_year)
# Calculate terminal value
terminal_fcf = projected_fcf[-1] * (1 + terminal_growth)
terminal_value = terminal_fcf / (discount_rate - terminal_growth)
# Discount cash flows to present
present_value_fcf = []
for year, fcf in enumerate(projected_fcf, 1):
pv = fcf / ((1 + discount_rate) ** year)
present_value_fcf.append(pv)
# Discount terminal value
pv_terminal = terminal_value / ((1 + discount_rate) ** years)
# Total enterprise value
enterprise_value = sum(present_value_fcf) + pv_terminal
# Adjust for net debt
net_debt = self.financial_data['total_debt'] - self.financial_data['cash_and_equivalents']
equity_value = enterprise_value - net_debt
# Value per share
shares_outstanding = self.financial_data['shares_outstanding']
if shares_outstanding <= 0:
return {'error': 'Invalid shares outstanding'}
intrinsic_value_per_share = equity_value / shares_outstanding
current_price = self.financial_data['current_price']
# Margin of safety
margin_of_safety = (intrinsic_value_per_share - current_price) / intrinsic_value_per_share if intrinsic_value_per_share > 0 else -1
return {
'ticker': self.ticker,
'intrinsic_value_per_share': intrinsic_value_per_share,
'current_price': current_price,
'margin_of_safety': margin_of_safety,
'enterprise_value': enterprise_value,
'equity_value': equity_value,
'projected_fcf': projected_fcf,
'terminal_value': terminal_value,
'assumptions': self.dcf_assumptions,
'recommendation': self.get_recommendation(margin_of_safety),
'upside_potential': (intrinsic_value_per_share / current_price - 1) if current_price > 0 else 0
}
def get_recommendation(self, margin_of_safety):
"""
Generate recommendation based on margin of safety
"""
if margin_of_safety > 0.3:
return "STRONG BUY - Margin of safety excelente"
elif margin_of_safety > 0.15:
return "BUY - Good margin of safety"
elif margin_of_safety > 0:
return "HOLD - Margin of safety marginal"
elif margin_of_safety > -0.15:
return "HOLD - Slightly overvalued"
else:
return "SELL - Significantly overvalued"
def sensitivity_analysis(self, growth_range=(-0.02, 0.02), discount_range=(-0.02, 0.02)):
"""
Sensitivity analysis for key assumptions
"""
base_valuation = self.calculate_dcf_valuation()
if 'error' in base_valuation:
return base_valuation
base_growth = self.dcf_assumptions['growth_rate']
base_discount = self.dcf_assumptions['discount_rate']
sensitivity_results = []
# Growth rate variations
for delta_growth in np.linspace(growth_range[0], growth_range[1], 5):
self.dcf_assumptions['growth_rate'] = base_growth + delta_growth
valuation = self.calculate_dcf_valuation()
if 'error' not in valuation:
sensitivity_results.append({
'parameter': 'growth_rate',
'value': base_growth + delta_growth,
'intrinsic_value': valuation['intrinsic_value_per_share'],
'margin_of_safety': valuation['margin_of_safety']
})
# Discount rate variations
for delta_discount in np.linspace(discount_range[0], discount_range[1], 5):
self.dcf_assumptions['growth_rate'] = base_growth # Reset
self.dcf_assumptions['discount_rate'] = base_discount + delta_discount
valuation = self.calculate_dcf_valuation()
if 'error' not in valuation:
sensitivity_results.append({
'parameter': 'discount_rate',
'value': base_discount + delta_discount,
'intrinsic_value': valuation['intrinsic_value_per_share'],
'margin_of_safety': valuation['margin_of_safety']
})
# Restore base assumptions
self.dcf_assumptions['growth_rate'] = base_growth
self.dcf_assumptions['discount_rate'] = base_discount
return {
'base_valuation': base_valuation,
'sensitivity_results': sensitivity_results
}
def dcf_screening(tickers, min_margin_of_safety=0.15):
"""
Screening multiple stocks using DCF
"""
results = []
for ticker in tickers:
print(f"Analyzing {ticker}...")
try:
valuator = DCFValuator(ticker)
valuation = valuator.calculate_dcf_valuation()
if 'error' not in valuation:
# Only include if minimum criteria are met
if (valuation['margin_of_safety'] > min_margin_of_safety and
valuation['intrinsic_value_per_share'] > 0):
results.append(valuation)
except Exception as e:
print(f"Error analyzing {ticker}: {e}")
continue
# Sort by margin of safety
results.sort(key=lambda x: x['margin_of_safety'], reverse=True)
return results
# Usage example
def dcf_example_analysis():
"""
Complete DCF analysis example
"""
ticker = "AAPL"
print(f"=== DCF ANALYSIS: {ticker} ===\\n")
# Create valuator
valuator = DCFValuator(ticker)
# Get data
if not valuator.get_financial_data():
print("❌ Error getting financial data")
return
# Show key data
print("📊 KEY FINANCIAL DATA:")
for key, value in valuator.financial_data.items():
if isinstance(value, (int, float)):
if abs(value) > 1e9:
print(f" {key}: ${value/1e9:.2f}B")
elif abs(value) > 1e6:
print(f" {key}: ${value/1e6:.2f}M")
else:
print(f" {key}: ${value:,.2f}")
# Calculate growth rates
growth_rates = valuator.calculate_growth_rates()
print(f"\\n📈 HISTORICAL GROWTH RATES:")
print(f" FCF Growth: {growth_rates['fcf_growth']:.1%}")
print(f" Revenue Growth: {growth_rates['revenue_growth']:.1%}")
# Set assumptions (using historical growth)
assumptions = valuator.set_dcf_assumptions(
growth_rate=growth_rates['fcf_growth']
)
print(f"\\n⚙️ DCF ASSUMPTIONS:")
for key, value in assumptions.items():
if isinstance(value, float):
print(f" {key}: {value:.1%}")
else:
print(f" {key}: {value}")
# Calculate valuation
valuation = valuator.calculate_dcf_valuation()
if 'error' in valuation:
print(f"❌ Error in valuation: {valuation['error']}")
return
print(f"\\n💰 DCF RESULTS:")
print(f" Intrinsic Value: ${valuation['intrinsic_value_per_share']:.2f}")
print(f" Current Price: ${valuation['current_price']:.2f}")
print(f" Margin of Safety: {valuation['margin_of_safety']:.1%}")
print(f" Upside Potential: {valuation['upside_potential']:.1%}")
print(f" Recommendation: {valuation['recommendation']}")
# Sensitivity analysis
print(f"\\n🔍 SENSITIVITY ANALYSIS:")
sensitivity = valuator.sensitivity_analysis()
if 'error' not in sensitivity:
print(f" Range of intrinsic values:")
values = [result['intrinsic_value'] for result in sensitivity['sensitivity_results']]
print(f" Minimum: ${min(values):.2f}")
print(f" Maximum: ${max(values):.2f}")
print(f" Range: ±{(max(values) - min(values))/2:.2f}")
return valuation
if __name__ == "__main__":
dcf_example_analysis()
Joel Greenblatt’s Magic Formula
The Magic Formula combines profitability (ROC) and valuation (Earnings Yield) to identify high-quality undervalued stocks.
Complete Implementation
class MagicFormulaScreener:
"""
Joel Greenblatt's Magic Formula Implementation
"""
def __init__(self, min_market_cap=50e6):
"""
Parameters
----------
min_market_cap : float
Minimum market capitalization in USD
"""
self.min_market_cap = min_market_cap
self.results = []
def calculate_metrics(self, ticker):
"""
Calculate Magic Formula metrics for a stock
Returns
-------
dict
Calculated metrics or None on error
"""
try:
stock = yf.Ticker(ticker)
# Get data necesarios
info = stock.info
financials = stock.financials
balance_sheet = stock.balance_sheet
# Verify minimum market capitalization
market_cap = info.get('marketCap', 0)
if market_cap < self.min_market_cap:
return None
# Basic data
current_price = info.get('currentPrice', 0)
shares_outstanding = info.get('sharesOutstanding', 0)
if current_price <= 0 or shares_outstanding <= 0:
return None
# EBIT (Earnings Before Interest and Taxes)
if "EBIT" in financials.index:
ebit = financials.loc["EBIT"].iloc[0]
else:
# Calculate approximate EBIT
operating_income = financials.loc["Operating Income"].iloc[0] if "Operating Income" in financials.index else 0
ebit = operating_income
if ebit <= 0:
return None
# Net Income
net_income = financials.loc["Net Income"].iloc[0] if "Net Income" in financials.index else 0
# Balance Sheet items
total_assets = balance_sheet.loc["Total Assets"].iloc[0] if "Total Assets" in balance_sheet.index else 0
current_liabilities = balance_sheet.loc["Current Liabilities"].iloc[0] if "Current Liabilities" in balance_sheet.index else 0
# Working Capital
working_capital = total_assets - current_liabilities
if working_capital <= 0:
return None
# 1. Return on Capital (ROC)
# ROC = EBIT / Working Capital
roc = (ebit / working_capital) * 100
# 2. Earnings Yield (EY)
# EY = EBIT / Market Cap
earnings_yield = (ebit / market_cap) * 100
# P/E Ratio for reference
pe_ratio = market_cap / net_income if net_income > 0 else float('inf')
return {
'ticker': ticker,
'market_cap': market_cap,
'current_price': current_price,
'roc': roc,
'earnings_yield': earnings_yield,
'pe_ratio': pe_ratio,
'ebit': ebit,
'working_capital': working_capital,
'net_income': net_income
}
except Exception as e:
print(f"Error calculating metrics for {ticker}: {e}")
return None
def screen_stocks(self, tickers):
"""
Apply Magic Formula screening to ticker list
Parameters
----------
tickers : list
List of stock symbols
Returns
-------
pd.DataFrame
Results sorted by ranking
"""
results = []
print(f"Screening {len(tickers)} stocks with Magic Formula...")
for ticker in tickers:
print(f"Analyzing {ticker}...")
metrics = self.calculate_metrics(ticker)
if metrics is not None:
results.append(metrics)
if not results:
print("❌ No valid stocks found")
return pd.DataFrame()
# Convert to DataFrame
df = pd.DataFrame(results)
# Calculate rankings
# Ranking ROC (1 = mejor ROC)
df['roc_rank'] = df['roc'].rank(ascending=False, na_option='bottom')
# Ranking Earnings Yield (1 = mejor EY)
df['ey_rank'] = df['earnings_yield'].rank(ascending=False, na_option='bottom')
# Combined ranking (lower is better)
df['magic_formula_rank'] = df['roc_rank'] + df['ey_rank']
# Sort by ranking
df = df.sort_values('magic_formula_rank')
# Add percentiles
df['roc_percentile'] = df['roc'].rank(pct=True) * 100
df['ey_percentile'] = df['earnings_yield'].rank(pct=True) * 100
self.results = df
return df
def get_top_stocks(self, n=10):
"""
Get the top n stocks according to Magic Formula
"""
if self.results.empty:
return pd.DataFrame()
return self.results.head(n)
def analyze_results(self):
"""
Statistical analysis of results
"""
if self.results.empty:
return {}
stats = {
'total_stocks_analyzed': len(self.results),
'average_roc': self.results['roc'].mean(),
'average_earnings_yield': self.results['earnings_yield'].mean(),
'average_pe_ratio': self.results['pe_ratio'].mean(),
'median_market_cap': self.results['market_cap'].median(),
'roc_stats': self.results['roc'].describe(),
'ey_stats': self.results['earnings_yield'].describe()
}
return stats
def backtest_strategy(self, start_date="2023-01-01", end_date="2024-01-01", top_n=10):
"""
Simple backtest of Magic Formula strategy
"""
if self.results.empty:
return {'error': 'No results for backtest'}
# Select top stocks
top_stocks = self.get_top_stocks(top_n)
tickers = top_stocks['ticker'].tolist()
try:
# Get historical prices
price_data = yf.download(tickers, start=start_date, end=end_date)['Close']
if price_data.empty:
return {'error': 'Could not get price data'}
# Calculate returns
returns = price_data.pct_change().dropna()
# Equal-weighted portfolio return
portfolio_returns = returns.mean(axis=1)
# Benchmark (SPY)
spy_data = yf.download('SPY', start=start_date, end=end_date)['Close']
spy_returns = spy_data.pct_change().dropna()
# Performance metrics
portfolio_cumret = (1 + portfolio_returns).cumprod().iloc[-1] - 1
spy_cumret = (1 + spy_returns).cumprod().iloc[-1] - 1
portfolio_volatility = portfolio_returns.std() * np.sqrt(252)
spy_volatility = spy_returns.std() * np.sqrt(252)
portfolio_sharpe = portfolio_returns.mean() / portfolio_returns.std() * np.sqrt(252)
spy_sharpe = spy_returns.mean() / spy_returns.std() * np.sqrt(252)
return {
'portfolio_return': portfolio_cumret,
'spy_return': spy_cumret,
'outperformance': portfolio_cumret - spy_cumret,
'portfolio_volatility': portfolio_volatility,
'spy_volatility': spy_volatility,
'portfolio_sharpe': portfolio_sharpe,
'spy_sharpe': spy_sharpe,
'stocks_used': tickers,
'period': f"{start_date} to {end_date}"
}
except Exception as e:
return {'error': f'Error in backtest: {e}'}
def magic_formula_example():
"""
Complete Magic Formula example
"""
print("=== JOEL GREENBLATT'S MAGIC FORMULA ===\\n")
# List of tickers to analyze (expand as needed)
tickers = [
"AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "META", "NFLX", "NVDA",
"JPM", "JNJ", "PG", "KO", "DIS", "INTC", "CSCO", "VZ",
"PFE", "WMT", "HD", "MRK", "ABBV", "CRM", "ADBE", "TMO"
]
# Create screener
screener = MagicFormulaScreener(min_market_cap=1e9) # Min $1B market cap
# Run screening
results = screener.screen_stocks(tickers)
if results.empty:
print("❌ No results found")
return
print(f"✅ Analyzed {len(results)} stocks\\n")
# Show top 10
top_10 = screener.get_top_stocks(10)
print("🏆 TOP 10 ACCORDING TO MAGIC FORMULA:")
print(top_10[['ticker', 'roc', 'earnings_yield', 'pe_ratio', 'magic_formula_rank']].round(2))
# Statistics
stats = screener.analyze_results()
print(f"\\n📊 STATISTICS:")
print(f" Average ROC: {stats['average_roc']:.1f}%")
print(f" Average Earnings Yield: {stats['average_earnings_yield']:.1f}%")
print(f" Average P/E: {stats['average_pe_ratio']:.1f}")
print(f" Median Market Cap: ${stats['median_market_cap']/1e9:.1f}B")
# Backtest
backtest = screener.backtest_strategy(
start_date="2023-01-01",
end_date="2024-01-01",
top_n=5
)
if 'error' not in backtest:
print(f"\\n📈 BACKTEST (Top 5 stocks):")
print(f" Portfolio Return: {backtest['portfolio_return']:.1%}")
print(f" SPY Return: {backtest['spy_return']:.1%}")
print(f" Outperformance: {backtest['outperformance']:.1%}")
print(f" Portfolio Sharpe: {backtest['portfolio_sharpe']:.2f}")
print(f" SPY Sharpe: {backtest['spy_sharpe']:.2f}")
return screener
if __name__ == "__main__":
magic_formula_example()
Advanced Financial Metrics
1. Quality Score
def calculate_quality_score(ticker):
"""
Calculate quality score for a company
"""
stock = yf.Ticker(ticker)
try:
info = stock.info
financials = stock.financials
balance_sheet = stock.balance_sheet
quality_metrics = {}
# 1. Profitability
net_margin = info.get('profitMargins', 0) * 100
roa = info.get('returnOnAssets', 0) * 100
roe = info.get('returnOnEquity', 0) * 100
# 2. Financial Strength
debt_to_equity = info.get('debtToEquity', 0)
current_ratio = info.get('currentRatio', 0)
quick_ratio = info.get('quickRatio', 0)
# 3. Growth
revenue_growth = info.get('revenueGrowth', 0) * 100
earnings_growth = info.get('earningsGrowth', 0) * 100
# 4. Efficiency
asset_turnover = info.get('assetTurnover', 0)
inventory_turnover = info.get('inventoryTurnover', 0)
# Calculate composite score
profitability_score = min((net_margin + roa + roe) / 3, 100)
strength_score = min((10 - debt_to_equity + current_ratio + quick_ratio) * 10, 100)
growth_score = min((revenue_growth + earnings_growth) / 2, 100)
efficiency_score = min((asset_turnover + inventory_turnover) * 50, 100)
quality_score = (profitability_score + strength_score + growth_score + efficiency_score) / 4
return {
'ticker': ticker,
'quality_score': quality_score,
'profitability_score': profitability_score,
'strength_score': strength_score,
'growth_score': growth_score,
'efficiency_score': efficiency_score,
'individual_metrics': {
'net_margin': net_margin,
'roa': roa,
'roe': roe,
'debt_to_equity': debt_to_equity,
'current_ratio': current_ratio,
'revenue_growth': revenue_growth,
'earnings_growth': earnings_growth
}
}
except Exception as e:
return {'error': f'Error calculando quality score para {ticker}: {e}'}
2. Value Investing Screener
class ValueInvestingScreener:
"""
Complete screener for value investing
"""
def __init__(self):
self.criteria = {
'max_pe': 15, # P/E < 15
'max_pb': 1.5, # P/B < 1.5
'min_dividend_yield': 2, # Dividend Yield > 2%
'max_debt_equity': 0.5, # Debt/Equity < 0.5
'min_roe': 15, # ROE > 15%
'min_roa': 5, # ROA > 5%
'min_current_ratio': 1.5 # Current Ratio > 1.5
}
def screen_stock(self, ticker):
"""
Evaluate a stock against value criteria
"""
try:
stock = yf.Ticker(ticker)
info = stock.info
metrics = {
'ticker': ticker,
'pe_ratio': info.get('trailingPE', float('inf')),
'pb_ratio': info.get('priceToBook', float('inf')),
'dividend_yield': info.get('dividendYield', 0) * 100 if info.get('dividendYield') else 0,
'debt_to_equity': info.get('debtToEquity', float('inf')),
'roe': info.get('returnOnEquity', 0) * 100 if info.get('returnOnEquity') else 0,
'roa': info.get('returnOnAssets', 0) * 100 if info.get('returnOnAssets') else 0,
'current_ratio': info.get('currentRatio', 0)
}
# Evaluate criteria
passed_criteria = 0
total_criteria = len(self.criteria)
if metrics['pe_ratio'] < self.criteria['max_pe']:
passed_criteria += 1
if metrics['pb_ratio'] < self.criteria['max_pb']:
passed_criteria += 1
if metrics['dividend_yield'] > self.criteria['min_dividend_yield']:
passed_criteria += 1
if metrics['debt_to_equity'] < self.criteria['max_debt_equity']:
passed_criteria += 1
if metrics['roe'] > self.criteria['min_roe']:
passed_criteria += 1
if metrics['roa'] > self.criteria['min_roa']:
passed_criteria += 1
if metrics['current_ratio'] > self.criteria['min_current_ratio']:
passed_criteria += 1
metrics['value_score'] = (passed_criteria / total_criteria) * 100
metrics['passed_criteria'] = passed_criteria
metrics['total_criteria'] = total_criteria
return metrics
except Exception as e:
return {'error': f'Error screening {ticker}: {e}'}
def batch_screen(self, tickers, min_score=70):
"""
Screen multiple stocks
"""
results = []
for ticker in tickers:
result = self.screen_stock(ticker)
if 'error' not in result and result['value_score'] >= min_score:
results.append(result)
# Sort by score
results.sort(key=lambda x: x['value_score'], reverse=True)
return results
Integration with Quantitative Trading
1. Fundamental + Technical Strategy
def fundamental_technical_strategy(ticker, dcf_margin_threshold=0.15):
"""
Combine fundamental analysis with technical signals
"""
# Fundamental analysis
valuator = DCFValuator(ticker)
dcf_result = valuator.calculate_dcf_valuation()
if 'error' in dcf_result:
return {'error': 'Could not complete fundamental analysis'}
# Only consider if there is margin of safety
if dcf_result['margin_of_safety'] < dcf_margin_threshold:
return {'signal': 'HOLD', 'reason': 'Insufficient margin of safety'}
# Get data técnicos
end_date = datetime.now()
start_date = end_date - timedelta(days=252)
price_data = yf.download(ticker, start=start_date, end=end_date)
# Simple technical indicators
current_price = price_data['Close'].iloc[-1]
sma_50 = price_data['Close'].rolling(50).mean().iloc[-1]
sma_200 = price_data['Close'].rolling(200).mean().iloc[-1]
rsi = calculate_rsi(price_data['Close']).iloc[-1]
# Combined signal logic
fundamental_bullish = dcf_result['margin_of_safety'] > dcf_margin_threshold
technical_bullish = (current_price > sma_50 and
sma_50 > sma_200 and
rsi < 70)
if fundamental_bullish and technical_bullish:
signal = 'STRONG BUY'
elif fundamental_bullish and not technical_bullish:
signal = 'BUY (Wait for better entry)'
elif not fundamental_bullish and technical_bullish:
signal = 'HOLD (Technical only)'
else:
signal = 'SELL/AVOID'
return {
'ticker': ticker,
'signal': signal,
'dcf_analysis': dcf_result,
'technical_indicators': {
'current_price': current_price,
'sma_50': sma_50,
'sma_200': sma_200,
'rsi': rsi
},
'fundamental_score': dcf_result['margin_of_safety'],
'technical_score': int(technical_bullish)
}
def calculate_rsi(series, period=14):
"""Helper function for RSI"""
delta = series.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
return 100 - (100 / (1 + rs))
2. Portfolio Construction
def build_fundamental_portfolio(tickers, max_positions=10, min_margin_safety=0.2):
"""
Build portfolio based on fundamental analysis
"""
candidates = []
# Fundamental screening
for ticker in tickers:
try:
# DCF Analysis
valuator = DCFValuator(ticker)
dcf = valuator.calculate_dcf_valuation()
if ('error' not in dcf and
dcf['margin_of_safety'] > min_margin_safety):
# Quality metrics
quality = calculate_quality_score(ticker)
if 'error' not in quality:
candidates.append({
'ticker': ticker,
'margin_of_safety': dcf['margin_of_safety'],
'upside_potential': dcf['upside_potential'],
'quality_score': quality['quality_score'],
'composite_score': (dcf['margin_of_safety'] * 0.6 +
quality['quality_score']/100 * 0.4)
})
except Exception as e:
continue
# Select best candidates
candidates.sort(key=lambda x: x['composite_score'], reverse=True)
selected = candidates[:max_positions]
# Calculate weights (based on composite score)
total_score = sum(stock['composite_score'] for stock in selected)
for stock in selected:
stock['weight'] = stock['composite_score'] / total_score
return selected
Evaluation Metrics
Performance Tracking
def track_fundamental_performance(portfolio, start_date, end_date):
"""
Track fundamental portfolio performance
"""
tickers = [stock['ticker'] for stock in portfolio]
weights = [stock['weight'] for stock in portfolio]
# Get prices
price_data = yf.download(tickers, start=start_date, end=end_date)['Close']
returns = price_data.pct_change().dropna()
# Portfolio returns
portfolio_returns = (returns * weights).sum(axis=1)
# Benchmark
spy_data = yf.download('SPY', start=start_date, end=end_date)['Close']
spy_returns = spy_data.pct_change().dropna()
# Metrics
portfolio_cumret = (1 + portfolio_returns).cumprod().iloc[-1] - 1
spy_cumret = (1 + spy_returns).cumprod().iloc[-1] - 1
portfolio_sharpe = portfolio_returns.mean() / portfolio_returns.std() * np.sqrt(252)
spy_sharpe = spy_returns.mean() / spy_returns.std() * np.sqrt(252)
# Calculate max drawdown
portfolio_cumulative = (1 + portfolio_returns).cumprod()
portfolio_drawdown = (portfolio_cumulative / portfolio_cumulative.cummax() - 1).min()
return {
'portfolio_return': portfolio_cumret,
'benchmark_return': spy_cumret,
'outperformance': portfolio_cumret - spy_cumret,
'portfolio_sharpe': portfolio_sharpe,
'benchmark_sharpe': spy_sharpe,
'max_drawdown': portfolio_drawdown,
'win_rate': (portfolio_returns > spy_returns).mean()
}
Limitations and Best Practices
Limitations of Fundamental Analysis
- Historical data: Financial statements reflect the past
- Accounting practices: Different accounting methods can distort metrics
- Market timing: Value may take years to be realized
- Small caps data: Less reliable or available data
Best Practices
FUNDAMENTAL_BEST_PRACTICES = {
'data_quality': {
'min_market_cap': 50e6, # Minimum $50M for reliable data
'max_pe_outlier': 100, # Avoid extreme P/E
'min_trading_volume': 100000 # Minimum daily volume
},
'valuation': {
'dcf_sensitivity': True, # Always perform sensitivity analysis
'multiple_methods': True, # Use DCF + comparable multiples
'margin_of_safety': 0.20, # Minimum 20% margin of safety
'growth_cap': 0.15 # Growth cap at 15% annual
},
'portfolio': {
'max_position_size': 0.10, # Maximum 10% per position
'diversification': True, # Diversify by sectors
'rebalancing': 'quarterly', # Rebalance quarterly
'fundamental_weight': 0.70 # 70% fundamental weight, 30% technical
}
}
Next Step
With Fundamental Analysis completed, you now have a full arsenal of professional quantitative tools. The documentation covers everything from technical indicators to machine learning and fundamental analysis, providing a solid foundation for institutional-grade quantitative trading.