366 lines
13 KiB
Python
366 lines
13 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# /// script
|
||
|
|
# requires-python = ">=3.10"
|
||
|
|
# dependencies = [
|
||
|
|
# "yfinance>=0.2.40",
|
||
|
|
# "pandas>=2.0.0",
|
||
|
|
# ]
|
||
|
|
# ///
|
||
|
|
"""
|
||
|
|
Dividend Analysis Module.
|
||
|
|
|
||
|
|
Analyzes dividend metrics for income investors:
|
||
|
|
- Dividend Yield
|
||
|
|
- Payout Ratio
|
||
|
|
- Dividend Growth Rate (5Y CAGR)
|
||
|
|
- Dividend Safety Score
|
||
|
|
- Ex-Dividend Date
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
uv run dividends.py AAPL
|
||
|
|
uv run dividends.py JNJ PG KO --output json
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import sys
|
||
|
|
from dataclasses import dataclass, asdict
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
import pandas as pd
|
||
|
|
import yfinance as yf
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class DividendAnalysis:
|
||
|
|
ticker: str
|
||
|
|
company_name: str
|
||
|
|
|
||
|
|
# Basic metrics
|
||
|
|
dividend_yield: float | None # Annual yield %
|
||
|
|
annual_dividend: float | None # Annual dividend per share
|
||
|
|
current_price: float | None
|
||
|
|
|
||
|
|
# Payout analysis
|
||
|
|
payout_ratio: float | None # Dividend / EPS
|
||
|
|
payout_status: str # "safe", "moderate", "high", "unsustainable"
|
||
|
|
|
||
|
|
# Growth
|
||
|
|
dividend_growth_5y: float | None # 5-year CAGR %
|
||
|
|
consecutive_years: int | None # Years of consecutive increases
|
||
|
|
dividend_history: list[dict] | None # Last 5 years
|
||
|
|
|
||
|
|
# Timing
|
||
|
|
ex_dividend_date: str | None
|
||
|
|
payment_frequency: str | None # "quarterly", "monthly", "annual"
|
||
|
|
|
||
|
|
# Safety score (0-100)
|
||
|
|
safety_score: int
|
||
|
|
safety_factors: list[str]
|
||
|
|
|
||
|
|
# Verdict
|
||
|
|
income_rating: str # "excellent", "good", "moderate", "poor", "no_dividend"
|
||
|
|
summary: str
|
||
|
|
|
||
|
|
|
||
|
|
def analyze_dividends(ticker: str, verbose: bool = False) -> DividendAnalysis | None:
|
||
|
|
"""Analyze dividend metrics for a stock."""
|
||
|
|
try:
|
||
|
|
stock = yf.Ticker(ticker)
|
||
|
|
info = stock.info
|
||
|
|
|
||
|
|
company_name = info.get("longName") or info.get("shortName") or ticker
|
||
|
|
current_price = info.get("regularMarketPrice") or info.get("currentPrice")
|
||
|
|
|
||
|
|
# Basic dividend info
|
||
|
|
dividend_yield = info.get("dividendYield")
|
||
|
|
if dividend_yield:
|
||
|
|
dividend_yield = dividend_yield * 100 # Convert to percentage
|
||
|
|
|
||
|
|
annual_dividend = info.get("dividendRate")
|
||
|
|
|
||
|
|
# No dividend
|
||
|
|
if not annual_dividend or annual_dividend == 0:
|
||
|
|
return DividendAnalysis(
|
||
|
|
ticker=ticker,
|
||
|
|
company_name=company_name,
|
||
|
|
dividend_yield=None,
|
||
|
|
annual_dividend=None,
|
||
|
|
current_price=current_price,
|
||
|
|
payout_ratio=None,
|
||
|
|
payout_status="no_dividend",
|
||
|
|
dividend_growth_5y=None,
|
||
|
|
consecutive_years=None,
|
||
|
|
dividend_history=None,
|
||
|
|
ex_dividend_date=None,
|
||
|
|
payment_frequency=None,
|
||
|
|
safety_score=0,
|
||
|
|
safety_factors=["No dividend paid"],
|
||
|
|
income_rating="no_dividend",
|
||
|
|
summary=f"{ticker} does not pay a dividend.",
|
||
|
|
)
|
||
|
|
|
||
|
|
# Payout ratio
|
||
|
|
trailing_eps = info.get("trailingEps")
|
||
|
|
payout_ratio = None
|
||
|
|
payout_status = "unknown"
|
||
|
|
|
||
|
|
if trailing_eps and trailing_eps > 0 and annual_dividend:
|
||
|
|
payout_ratio = (annual_dividend / trailing_eps) * 100
|
||
|
|
|
||
|
|
if payout_ratio < 40:
|
||
|
|
payout_status = "safe"
|
||
|
|
elif payout_ratio < 60:
|
||
|
|
payout_status = "moderate"
|
||
|
|
elif payout_ratio < 80:
|
||
|
|
payout_status = "high"
|
||
|
|
else:
|
||
|
|
payout_status = "unsustainable"
|
||
|
|
|
||
|
|
# Dividend history (for growth calculation)
|
||
|
|
dividends = stock.dividends
|
||
|
|
dividend_history = None
|
||
|
|
dividend_growth_5y = None
|
||
|
|
consecutive_years = None
|
||
|
|
|
||
|
|
if dividends is not None and len(dividends) > 0:
|
||
|
|
# Group by year
|
||
|
|
dividends_df = dividends.reset_index()
|
||
|
|
dividends_df["Year"] = pd.to_datetime(dividends_df["Date"]).dt.year
|
||
|
|
yearly = dividends_df.groupby("Year")["Dividends"].sum().sort_index(ascending=False)
|
||
|
|
|
||
|
|
# Last 5 years history
|
||
|
|
dividend_history = []
|
||
|
|
for year in yearly.head(5).index:
|
||
|
|
dividend_history.append({
|
||
|
|
"year": int(year),
|
||
|
|
"total": round(float(yearly[year]), 4),
|
||
|
|
})
|
||
|
|
|
||
|
|
# Calculate 5-year CAGR
|
||
|
|
if len(yearly) >= 5:
|
||
|
|
current_div = yearly.iloc[0]
|
||
|
|
div_5y_ago = yearly.iloc[4]
|
||
|
|
|
||
|
|
if div_5y_ago > 0 and current_div > 0:
|
||
|
|
dividend_growth_5y = ((current_div / div_5y_ago) ** (1/5) - 1) * 100
|
||
|
|
|
||
|
|
# Count consecutive years of increases
|
||
|
|
consecutive_years = 0
|
||
|
|
prev_div = None
|
||
|
|
for div in yearly.values:
|
||
|
|
if prev_div is not None:
|
||
|
|
if div >= prev_div:
|
||
|
|
consecutive_years += 1
|
||
|
|
else:
|
||
|
|
break
|
||
|
|
prev_div = div
|
||
|
|
|
||
|
|
# Ex-dividend date
|
||
|
|
ex_dividend_date = info.get("exDividendDate")
|
||
|
|
if ex_dividend_date:
|
||
|
|
ex_dividend_date = datetime.fromtimestamp(ex_dividend_date).strftime("%Y-%m-%d")
|
||
|
|
|
||
|
|
# Payment frequency
|
||
|
|
payment_frequency = None
|
||
|
|
if dividends is not None and len(dividends) >= 4:
|
||
|
|
# Count dividends in last year
|
||
|
|
one_year_ago = pd.Timestamp.now() - pd.DateOffset(years=1)
|
||
|
|
recent_divs = dividends[dividends.index > one_year_ago]
|
||
|
|
count = len(recent_divs)
|
||
|
|
|
||
|
|
if count >= 10:
|
||
|
|
payment_frequency = "monthly"
|
||
|
|
elif count >= 3:
|
||
|
|
payment_frequency = "quarterly"
|
||
|
|
elif count >= 1:
|
||
|
|
payment_frequency = "annual"
|
||
|
|
|
||
|
|
# Safety score calculation (0-100)
|
||
|
|
safety_score = 50 # Base score
|
||
|
|
safety_factors = []
|
||
|
|
|
||
|
|
# Payout ratio factor (+/- 20)
|
||
|
|
if payout_ratio:
|
||
|
|
if payout_ratio < 40:
|
||
|
|
safety_score += 20
|
||
|
|
safety_factors.append(f"Low payout ratio ({payout_ratio:.0f}%)")
|
||
|
|
elif payout_ratio < 60:
|
||
|
|
safety_score += 10
|
||
|
|
safety_factors.append(f"Moderate payout ratio ({payout_ratio:.0f}%)")
|
||
|
|
elif payout_ratio < 80:
|
||
|
|
safety_score -= 10
|
||
|
|
safety_factors.append(f"High payout ratio ({payout_ratio:.0f}%)")
|
||
|
|
else:
|
||
|
|
safety_score -= 20
|
||
|
|
safety_factors.append(f"Unsustainable payout ratio ({payout_ratio:.0f}%)")
|
||
|
|
|
||
|
|
# Growth factor (+/- 15)
|
||
|
|
if dividend_growth_5y:
|
||
|
|
if dividend_growth_5y > 10:
|
||
|
|
safety_score += 15
|
||
|
|
safety_factors.append(f"Strong dividend growth ({dividend_growth_5y:.1f}% CAGR)")
|
||
|
|
elif dividend_growth_5y > 5:
|
||
|
|
safety_score += 10
|
||
|
|
safety_factors.append(f"Good dividend growth ({dividend_growth_5y:.1f}% CAGR)")
|
||
|
|
elif dividend_growth_5y > 0:
|
||
|
|
safety_score += 5
|
||
|
|
safety_factors.append(f"Positive dividend growth ({dividend_growth_5y:.1f}% CAGR)")
|
||
|
|
else:
|
||
|
|
safety_score -= 15
|
||
|
|
safety_factors.append(f"Dividend declining ({dividend_growth_5y:.1f}% CAGR)")
|
||
|
|
|
||
|
|
# Consecutive years factor (+/- 15)
|
||
|
|
if consecutive_years:
|
||
|
|
if consecutive_years >= 25:
|
||
|
|
safety_score += 15
|
||
|
|
safety_factors.append(f"Dividend Aristocrat ({consecutive_years}+ years)")
|
||
|
|
elif consecutive_years >= 10:
|
||
|
|
safety_score += 10
|
||
|
|
safety_factors.append(f"Long dividend history ({consecutive_years} years)")
|
||
|
|
elif consecutive_years >= 5:
|
||
|
|
safety_score += 5
|
||
|
|
safety_factors.append(f"Consistent dividend ({consecutive_years} years)")
|
||
|
|
|
||
|
|
# Yield factor (high yield can be risky)
|
||
|
|
if dividend_yield:
|
||
|
|
if dividend_yield > 8:
|
||
|
|
safety_score -= 10
|
||
|
|
safety_factors.append(f"Very high yield ({dividend_yield:.1f}%) - verify sustainability")
|
||
|
|
elif dividend_yield < 1:
|
||
|
|
safety_factors.append(f"Low yield ({dividend_yield:.2f}%)")
|
||
|
|
|
||
|
|
# Clamp score
|
||
|
|
safety_score = max(0, min(100, safety_score))
|
||
|
|
|
||
|
|
# Income rating
|
||
|
|
if safety_score >= 80:
|
||
|
|
income_rating = "excellent"
|
||
|
|
elif safety_score >= 60:
|
||
|
|
income_rating = "good"
|
||
|
|
elif safety_score >= 40:
|
||
|
|
income_rating = "moderate"
|
||
|
|
else:
|
||
|
|
income_rating = "poor"
|
||
|
|
|
||
|
|
# Summary
|
||
|
|
summary_parts = []
|
||
|
|
if dividend_yield:
|
||
|
|
summary_parts.append(f"{dividend_yield:.2f}% yield")
|
||
|
|
if payout_ratio:
|
||
|
|
summary_parts.append(f"{payout_ratio:.0f}% payout")
|
||
|
|
if dividend_growth_5y:
|
||
|
|
summary_parts.append(f"{dividend_growth_5y:+.1f}% 5Y growth")
|
||
|
|
if consecutive_years and consecutive_years >= 5:
|
||
|
|
summary_parts.append(f"{consecutive_years}Y streak")
|
||
|
|
|
||
|
|
summary = f"{ticker}: {', '.join(summary_parts)}. Rating: {income_rating.upper()}"
|
||
|
|
|
||
|
|
return DividendAnalysis(
|
||
|
|
ticker=ticker,
|
||
|
|
company_name=company_name,
|
||
|
|
dividend_yield=round(dividend_yield, 2) if dividend_yield else None,
|
||
|
|
annual_dividend=round(annual_dividend, 4) if annual_dividend else None,
|
||
|
|
current_price=current_price,
|
||
|
|
payout_ratio=round(payout_ratio, 1) if payout_ratio else None,
|
||
|
|
payout_status=payout_status,
|
||
|
|
dividend_growth_5y=round(dividend_growth_5y, 2) if dividend_growth_5y else None,
|
||
|
|
consecutive_years=consecutive_years,
|
||
|
|
dividend_history=dividend_history,
|
||
|
|
ex_dividend_date=ex_dividend_date,
|
||
|
|
payment_frequency=payment_frequency,
|
||
|
|
safety_score=safety_score,
|
||
|
|
safety_factors=safety_factors,
|
||
|
|
income_rating=income_rating,
|
||
|
|
summary=summary,
|
||
|
|
)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
if verbose:
|
||
|
|
print(f"Error analyzing {ticker}: {e}", file=sys.stderr)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def format_text(analysis: DividendAnalysis) -> str:
|
||
|
|
"""Format dividend analysis as text."""
|
||
|
|
lines = [
|
||
|
|
"=" * 60,
|
||
|
|
f"DIVIDEND ANALYSIS: {analysis.ticker} ({analysis.company_name})",
|
||
|
|
"=" * 60,
|
||
|
|
"",
|
||
|
|
]
|
||
|
|
|
||
|
|
if analysis.income_rating == "no_dividend":
|
||
|
|
lines.append("This stock does not pay a dividend.")
|
||
|
|
lines.append("=" * 60)
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
# Yield & Price
|
||
|
|
lines.append(f"Current Price: ${analysis.current_price:.2f}")
|
||
|
|
lines.append(f"Annual Dividend: ${analysis.annual_dividend:.2f}")
|
||
|
|
lines.append(f"Dividend Yield: {analysis.dividend_yield:.2f}%")
|
||
|
|
lines.append(f"Payment Freq: {analysis.payment_frequency or 'Unknown'}")
|
||
|
|
if analysis.ex_dividend_date:
|
||
|
|
lines.append(f"Ex-Dividend: {analysis.ex_dividend_date}")
|
||
|
|
|
||
|
|
lines.append("")
|
||
|
|
|
||
|
|
# Payout & Safety
|
||
|
|
lines.append(f"Payout Ratio: {analysis.payout_ratio:.1f}% ({analysis.payout_status})")
|
||
|
|
lines.append(f"5Y Div Growth: {analysis.dividend_growth_5y:+.1f}%" if analysis.dividend_growth_5y else "5Y Div Growth: N/A")
|
||
|
|
if analysis.consecutive_years:
|
||
|
|
lines.append(f"Consecutive Yrs: {analysis.consecutive_years}")
|
||
|
|
|
||
|
|
lines.append("")
|
||
|
|
lines.append(f"SAFETY SCORE: {analysis.safety_score}/100")
|
||
|
|
lines.append(f"INCOME RATING: {analysis.income_rating.upper()}")
|
||
|
|
|
||
|
|
lines.append("")
|
||
|
|
lines.append("Safety Factors:")
|
||
|
|
for factor in analysis.safety_factors:
|
||
|
|
lines.append(f" • {factor}")
|
||
|
|
|
||
|
|
# History
|
||
|
|
if analysis.dividend_history:
|
||
|
|
lines.append("")
|
||
|
|
lines.append("Dividend History:")
|
||
|
|
for h in analysis.dividend_history[:5]:
|
||
|
|
lines.append(f" {h['year']}: ${h['total']:.2f}")
|
||
|
|
|
||
|
|
lines.append("")
|
||
|
|
lines.append("=" * 60)
|
||
|
|
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description="Dividend Analysis")
|
||
|
|
parser.add_argument("tickers", nargs="+", help="Stock ticker(s)")
|
||
|
|
parser.add_argument("--output", choices=["text", "json"], default="text")
|
||
|
|
parser.add_argument("--verbose", "-v", action="store_true")
|
||
|
|
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
results = []
|
||
|
|
for ticker in args.tickers:
|
||
|
|
analysis = analyze_dividends(ticker.upper(), verbose=args.verbose)
|
||
|
|
if analysis:
|
||
|
|
results.append(analysis)
|
||
|
|
else:
|
||
|
|
print(f"Error: Could not analyze {ticker}", file=sys.stderr)
|
||
|
|
|
||
|
|
if args.output == "json":
|
||
|
|
if len(results) == 1:
|
||
|
|
print(json.dumps(asdict(results[0]), indent=2))
|
||
|
|
else:
|
||
|
|
print(json.dumps([asdict(r) for r in results], indent=2))
|
||
|
|
else:
|
||
|
|
for i, analysis in enumerate(results):
|
||
|
|
if i > 0:
|
||
|
|
print("\n")
|
||
|
|
print(format_text(analysis))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|