
Value Dividend Screener
- 1.3k installs
- 2.5k repo stars
- Updated July 26, 2026
- tradermonty/claude-trading-skills
value-dividend-screener is an agent skill for screen us stocks for high-quality dividend opportunities combining value characteristics (p/e ratio under 20, p/b ratio under 2), attractive yields (3% or higher), and.
About
The value-dividend-screener skill is designed for screen US stocks for high-quality dividend opportunities combining value characteristics (P/E ratio under 20, P/B ratio under 2), attractive yields (3% or higher), and. Value Dividend Screener Overview This skill identifies high-quality dividend stocks that combine value characteristics, attractive income generation, and consistent growth using a two-stage screening approach: 1. FINVIZ Elite API (Optional but Recommended): Pre-screen stocks with basic criteria (fast, cost-effective) 2. Invoke when the user user requests dividend stock screening, income portfolio ideas, or quality value stocks with strong fundamentals.
- "Find high-quality dividend stocks".
- "Screen for value dividend opportunities".
- "Show me stocks with strong dividend growth".
- "Find income stocks trading at reasonable valuations".
- "Screen for sustainable high-yield stocks".
Value Dividend Screener by the numbers
- 1,310 all-time installs (skills.sh)
- +46 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #106 of 1,136 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
value-dividend-screener capabilities & compatibility
- Capabilities
- "find high quality dividend stocks" · "screen for value dividend opportunities" · "show me stocks with strong dividend growth" · "find income stocks trading at reasonable valuat
What value-dividend-screener says it does
Screen US stocks for high-quality dividend opportunities combining value characteristics (P/E ratio under 20, P/B ratio under 2), attractive yields (3% or higher), and consistent g
Screen US stocks for high-quality dividend opportunities combining value characteristics (P/E ratio under 20, P/B ratio under 2), attractive yields (3% or highe
npx skills add https://github.com/tradermonty/claude-trading-skills --skill value-dividend-screenerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.3k |
|---|---|
| repo stars | ★ 2.5k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 26, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do I screen us stocks for high-quality dividend opportunities combining value characteristics (p/e ratio under 20, p/b ratio under 2), attractive yields (3% or higher), and?
Screen US stocks for high-quality dividend opportunities combining value characteristics (P/E ratio under 20, P/B ratio under 2), attractive yields (3% or higher), and.
Who is it for?
Developers using value dividend screener workflows documented in SKILL.md.
Skip if: Skip when the task falls outside value-dividend-screener scope or needs a different stack.
When should I use this skill?
User user requests dividend stock screening, income portfolio ideas, or quality value stocks with strong fundamentals.
What you get
Completed value-dividend-screener workflow with documented commands, files, and expected deliverables.
- Dividend stock candidate list
- FMP endpoint query results
By the numbers
- FMP free tier: 250 requests per day at ~5 requests per second
- Paid Starter tier: $14/month for 500 requests per day
Files
Value Dividend Screener
Overview
This skill identifies high-quality dividend stocks that combine value characteristics, attractive income generation, and consistent growth using a two-stage screening approach:
1. FINVIZ Elite API (Optional but Recommended): Pre-screen stocks with basic criteria (fast, cost-effective) 2. Financial Modeling Prep (FMP) API: Detailed fundamental analysis of candidates
Screen US equities based on quantitative criteria including valuation ratios, dividend metrics, financial health, and profitability. Generate comprehensive reports ranking stocks by composite quality scores with detailed fundamental analysis.
Efficiency Advantage: Using FINVIZ pre-screening can reduce FMP API calls by 90%, making this approach ideal for free-tier API users.
When to Use
Invoke this skill when the user requests:
- "Find high-quality dividend stocks"
- "Screen for value dividend opportunities"
- "Show me stocks with strong dividend growth"
- "Find income stocks trading at reasonable valuations"
- "Screen for sustainable high-yield stocks"
- Any request combining dividend yield, valuation metrics, and fundamental analysis
Workflow
Step 1: Verify API Key Availability
For Two-Stage Screening (Recommended):
Check if both API keys are available:
import os
fmp_api_key = os.environ.get('FMP_API_KEY')
finviz_api_key = os.environ.get('FINVIZ_API_KEY')If not available, ask user to provide API keys or set environment variables:
export FMP_API_KEY=your_fmp_key_here
export FINVIZ_API_KEY=your_finviz_key_hereFor FMP-Only Screening:
Check if FMP API key is available:
import os
api_key = os.environ.get('FMP_API_KEY')If not available, ask user to provide API key or set environment variable:
export FMP_API_KEY=your_key_hereFINVIZ Elite API Key:
- Requires FINVIZ Elite subscription (~$40/month or ~$330/year)
- Provides access to CSV export of pre-screened results
- Highly recommended for reducing FMP API usage
Provide instructions from references/fmp_api_guide.md if needed.
Step 2: Execute Screening Script
Run the screening script with appropriate parameters:
Two-Stage Screening (RECOMMENDED)
Uses FINVIZ for pre-screening, then FMP for detailed analysis:
Default execution (Top 20 stocks):
python3 scripts/screen_dividend_stocks.py --use-finvizWith explicit API keys:
python3 scripts/screen_dividend_stocks.py --use-finviz \
--fmp-api-key $FMP_API_KEY \
--finviz-api-key $FINVIZ_API_KEYCustom top N:
python3 scripts/screen_dividend_stocks.py --use-finviz --top 50Custom output location:
python3 scripts/screen_dividend_stocks.py --use-finviz --output /path/to/results.jsonScript behavior (Two-Stage): 1. FINVIZ Elite pre-screening:
- Market cap: Mid-cap or higher
- Dividend yield: 3%+
- Dividend growth (3Y): 5%+
- EPS growth (3Y): Positive
- P/B: Under 2
- P/E: Under 20
- Sales growth (3Y): Positive
- Geography: USA
2. FMP detailed analysis of FINVIZ results (typically 20-50 stocks):
- Dividend growth rate calculation (3-year CAGR)
- Revenue and EPS trend analysis
- Dividend sustainability assessment (payout ratios, FCF coverage)
- Financial health metrics (debt-to-equity, current ratio)
- Quality scoring (ROE, profit margins)
3. Composite scoring and ranking 4. Output top N stocks to JSON file
Expected runtime (Two-Stage): 2-3 minutes for 30-50 FINVIZ candidates (much faster than FMP-only)
FMP-Only Screening (Original Method)
Uses only FMP Stock Screener API (higher API usage):
Default execution:
python3 scripts/screen_dividend_stocks.pyWith explicit API key:
python3 scripts/screen_dividend_stocks.py --fmp-api-key $FMP_API_KEYScript behavior (FMP-Only): 1. Initial screening using FMP Stock Screener API (dividend yield >=3.0%, P/E <=20, P/B <=2) 2. Detailed analysis of candidates (typically 100-300 stocks):
- Same detailed analysis as two-stage approach
3. Composite scoring and ranking 4. Output top N stocks to JSON file
Expected runtime (FMP-Only): 5-15 minutes for 100-300 candidates (rate limiting applies)
API Usage Comparison:
- Two-Stage: ~50-100 FMP API calls (FINVIZ pre-filters to ~30 stocks)
- FMP-Only: ~500-1500 FMP API calls (analyzes all screener results)
Step 3: Parse and Analyze Results
Read the generated JSON file:
import json
with open('dividend_screener_results.json', 'r') as f:
data = json.load(f)
metadata = data['metadata']
stocks = data['stocks']Key data points per stock:
- Basic info:
symbol,company_name,sector,market_cap,price - Valuation:
dividend_yield,pe_ratio,pb_ratio - Growth metrics:
dividend_cagr_3y,revenue_cagr_3y,eps_cagr_3y - Sustainability:
payout_ratio,fcf_payout_ratio,dividend_sustainable - Financial health:
debt_to_equity,current_ratio,financially_healthy - Quality:
roe,profit_margin,quality_score - Overall ranking:
composite_score
Step 4: Generate Markdown Report
Create structured markdown report for user with following sections:
Report Structure
# Value Dividend Stock Screening Report
**Generated:** [Timestamp]
**Screening Criteria:**
- Dividend Yield: >= 3.5%
- P/E Ratio: <= 20
- P/B Ratio: <= 2
- Dividend Growth (3Y CAGR): >= 5%
- Revenue Trend: Positive over 3 years
- EPS Trend: Positive over 3 years
**Total Results:** [N] stocks
---
## Top 20 Stocks Ranked by Composite Score
| Rank | Symbol | Company | Yield | P/E | Div Growth | Score |
|------|--------|---------|-------|-----|------------|-------|
| 1 | [TICKER] | [Name] | [%] | [X.X] | [%] | [XX.X] |
| ... |
---
## Detailed Analysis
### 1. [SYMBOL] - [Company Name] (Score: XX.X)
**Sector:** [Sector Name]
**Market Cap:** $[X.XX]B
**Current Price:** $[XX.XX]
**Valuation Metrics:**
- Dividend Yield: [X.X]%
- P/E Ratio: [XX.X]
- P/B Ratio: [X.X]
**Growth Profile (3-Year):**
- Dividend CAGR: [X.X]% [✓ Consistent / ⚠ One cut]
- Revenue CAGR: [X.X]%
- EPS CAGR: [X.X]%
**Dividend Sustainability:**
- Payout Ratio: [XX]%
- FCF Payout Ratio: [XX]%
- Status: [✓ Sustainable / ⚠ Monitor / ❌ Risk]
**Financial Health:**
- Debt-to-Equity: [X.XX]
- Current Ratio: [X.XX]
- Status: [✓ Healthy / ⚠ Caution]
**Quality Metrics:**
- ROE: [XX]%
- Net Profit Margin: [XX]%
- Quality Score: [XX]/100
**Investment Considerations:**
- [Key strength 1]
- [Key strength 2]
- [Risk factor or consideration]
---
[Repeat for other top stocks]
---
## Portfolio Construction Guidance
**Diversification Recommendations:**
- Sector breakdown of top 20 results
- Suggested allocation strategy
- Concentration risk warnings
**Monitoring Recommendations:**
- Key metrics to track quarterly
- Warning signs for each position
- Rebalancing triggers
**Risk Considerations:**
- Market cap concentration
- Sector biases in results
- Economic sensitivity warningsStep 5: Provide Context and Methodology
Reference screening methodology when explaining results:
Key concepts to explain:
- Why these specific thresholds (3.5% yield, P/E 20, P/B 2)
- Importance of dividend growth vs. static high yield
- How composite score balances value, growth, and quality
- Dividend sustainability vs. dividend trap distinction
- Financial health metrics significance
Load references/screening_methodology.md to provide detailed explanations of:
- Phase 1: Initial quantitative filters
- Phase 2: Growth quality filters
- Phase 3: Sustainability and quality analysis
- Composite scoring system
- Investment philosophy and limitations
Step 6: Answer Follow-up Questions
Anticipate common user questions:
"Why did [stock] not make the list?"
- Check which criteria it failed (yield, valuation, growth, sustainability)
- Explain the specific filter that excluded it
"Can I screen for specific sectors?"
- Filtering capability exists in script (modify line 383-388)
- Suggest re-running with sector parameter additions
"What if I want higher/lower yield threshold?"
- Script parameters are adjustable
- Trade-offs between yield and growth
- Recommend re-screening with new parameters
"How often should I re-run this screen?"
- Quarterly recommended (aligns with earnings cycles)
- Semi-annually sufficient for long-term holders
- Market conditions may warrant more frequent checks
"How many stocks should I buy?"
- Diversification guidance: minimum 10-15 for dividend portfolio
- Sector balance considerations
- Position sizing based on risk tolerance
Resources
scripts/screen_dividend_stocks.py
Comprehensive screening script that:
- Interfaces with FMP API for data retrieval
- Implements multi-phase filtering logic
- Calculates growth rates (CAGR) over 3-year periods
- Evaluates dividend sustainability via payout ratios and FCF coverage
- Assesses financial health (debt-to-equity, current ratio)
- Computes quality scores (ROE, profit margins)
- Ranks stocks by composite scoring system
- Outputs structured JSON results
Dependencies: requests library (install via pip install requests)
Rate limiting: Built-in delays to respect FMP API limits (250 requests/day free tier)
Error handling: Graceful degradation for missing data, rate limit retries, API errors
references/screening_methodology.md
Comprehensive documentation of screening approach:
Phase 1: Initial Quantitative Filters
- Dividend yield >= 3.5% rationale and calculation
- P/E ratio <= 20 threshold justification
- P/B ratio <= 2 valuation logic
Phase 2: Growth Quality Filters
- Dividend growth (3-year CAGR >= 5%)
- Revenue positive trend analysis
- EPS positive trend analysis
Phase 3: Quality & Sustainability Analysis
- Dividend sustainability metrics (payout ratios, FCF coverage)
- Financial health indicators (D/E, current ratio)
- Quality scoring methodology (ROE, profit margins)
Composite Scoring System (0-100 points)
- Score component breakdown and weighting
- Interpretation guidelines
Investment Philosophy
- Why this approach works
- What this strategy avoids (dividend traps, value traps)
- Ideal candidate profile
Usage Notes & Limitations
- Best practices for portfolio construction
- When to sell criteria
- Historical context for threshold selection
references/fmp_api_guide.md
Complete guide for Financial Modeling Prep API:
API Key Setup
- Obtaining free API key
- Setting environment variables
- Free tier limits (250 requests/day)
Key Endpoints Used
- Stock Screener API
- Income Statement API
- Balance Sheet API
- Cash Flow Statement API
- Key Metrics API
- Historical Dividend API
Rate Limiting Strategy
- Built-in protection in script
- Request budget management
- Best practices for free tier
Error Handling
- Common errors and solutions
- Debugging techniques
Data Quality Considerations
- Data freshness and gaps
- Data accuracy caveats
- When to verify with SEC filings
Advanced Usage
Customizing Screening Criteria
Modify thresholds in scripts/screen_dividend_stocks.py:
Line 383-388 - Initial screening parameters:
candidates = client.screen_stocks(
dividend_yield_min=3.5, # Adjust yield threshold
pe_max=20, # Adjust P/E threshold
pb_max=2, # Adjust P/B threshold
market_cap_min=2_000_000_000 # Minimum $2B market cap
)Line 423 - Dividend CAGR threshold:
if not div_cagr or div_cagr < 5.0: # Adjust growth thresholdSector-Specific Screening
Add sector filtering after initial screening:
# Filter for specific sectors
target_sectors = ['Consumer Defensive', 'Utilities', 'Healthcare']
candidates = [s for s in candidates if s.get('sector') in target_sectors]Excluding REITs and Financials
REITs and financial stocks have different dividend characteristics (higher payouts, different metrics):
# Exclude REITs and Financials
exclude_sectors = ['Real Estate', 'Financial Services']
candidates = [s for s in candidates if s.get('sector') not in exclude_sectors]Exporting to CSV
Convert JSON results to CSV for Excel analysis:
import json
import csv
with open('dividend_screener_results.json', 'r') as f:
data = json.load(f)
stocks = data['stocks']
with open('screening_results.csv', 'w', newline='') as csvfile:
if stocks:
fieldnames = stocks[0].keys()
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(stocks)Troubleshooting
"ERROR: requests library not found"
Solution: Install requests library
pip install requests"ERROR: FMP API key required"
Solution: Set environment variable or provide via command-line
export FMP_API_KEY=your_key_here
# OR
python3 scripts/screen_dividend_stocks.py --fmp-api-key your_key_here"ERROR: FINVIZ API key required when using --use-finviz"
Solution: Set environment variable or provide via command-line
export FINVIZ_API_KEY=your_key_here
# OR
python3 scripts/screen_dividend_stocks.py --use-finviz --finviz-api-key your_key_hereNote: FINVIZ Elite subscription required (~$40/month or ~$330/year)
"ERROR: FINVIZ API authentication failed"
Possible causes: 1. Invalid FINVIZ API key 2. FINVIZ Elite subscription expired 3. API key format incorrect
Solution:
- Verify FINVIZ Elite subscription is active
- Check API key for typos (should be alphanumeric string)
- Log into FINVIZ Elite account and verify API key in settings
- Try accessing FINVIZ Elite screener manually to confirm subscription
"ERROR: FINVIZ pre-screening failed or returned no results"
Possible causes: 1. FINVIZ API connection issue 2. Screening criteria too restrictive (no stocks match) 3. Market conditions (bear market may yield fewer results)
Solution:
- Check internet connection
- Verify FINVIZ Elite website is accessible
- Try FMP-only method as fallback:
python3 scripts/screen_dividend_stocks.py"WARNING: Rate limit exceeded"
Solution: Script automatically retries after 60 seconds. If persistent:
- Wait until next day (free tier resets daily)
- Reduce number of stocks analyzed (modify line 394 limit)
- Consider upgrading to paid FMP tier
"No stocks found matching all criteria"
Solution: Criteria may be too restrictive
- Relax P/E threshold (increase from 20)
- Lower dividend yield requirement (decrease from 3.5%)
- Reduce dividend growth requirement (decrease from 5%)
- Check market conditions (bear markets may have fewer qualifiers)
Script runs slowly
Expected behavior: Script includes 0.3s delay between API calls for rate limiting
- 100 stocks analyzed = ~8-10 minutes
- First 20-30 qualifying stocks usually found within first 50-70 analyzed
Performance & Cost Optimization
API Call Comparison
Two-Stage Screening (FINVIZ + FMP):
- FINVIZ: 1 API call
- FMP Quote API: ~30-50 calls (one per pre-screened symbol)
- FMP Financial Data: ~150-250 calls (5 endpoints × 30-50 symbols)
- Total FMP calls: ~180-300
FMP-Only Screening:
- FMP Stock Screener: 1 call (returns 100-1000 stocks)
- FMP Financial Data: ~500-5000 calls (5 endpoints × 100-1000 symbols)
- Total FMP calls: ~500-5000
Savings: 60-94% reduction in FMP API usage
Cost Analysis
FINVIZ Elite:
- Monthly: $39.50
- Annual: $299.50 (~$24.96/month)
FMP API:
- Free tier: 250 calls/day (sufficient for two-stage screening)
- Starter tier: $29.99/month for 750 calls/day
- Professional tier: $79.99/month for 2000 calls/day
Recommendation:
- For free FMP tier users: Use two-stage screening (FINVIZ + FMP free tier)
- For paid FMP tier users: Either approach works; two-stage is faster
- Budget option: FMP-only with free tier (run screening every few days)
- Optimal option: FINVIZ Elite ($330/year) + FMP free tier = Complete solution
Version History
- v1.1 (November 2025): Added FINVIZ Elite integration for two-stage screening
- v1.0 (November 2025): Initial release with comprehensive multi-phase screening
Financial Modeling Prep (FMP) API Guide
Overview
Financial Modeling Prep provides comprehensive financial data APIs for stocks, forex, cryptocurrencies, and more. This guide focuses on endpoints used for dividend stock screening.
API Key Setup
Obtaining an API Key
1. Visit https://financialmodelingprep.com/developer/docs 2. Sign up for a free account 3. Navigate to Dashboard → API Keys 4. Copy your API key
Free Tier Limits
- 250 requests per day
- Rate limit: ~5 requests per second
- No credit card required
- Sufficient for daily/weekly screening runs
Paid Tiers (Optional)
- Starter ($14/month): 500 requests/day
- Professional ($29/month): 1,000 requests/day
- Enterprise ($99/month): 10,000 requests/day
Setting API Key
Method 1: Environment Variable (Recommended)
Linux/macOS:
export FMP_API_KEY=your_api_key_hereWindows (Command Prompt):
set FMP_API_KEY=your_api_key_hereWindows (PowerShell):
$env:FMP_API_KEY="your_api_key_here"Persistent (add to shell profile):
# Add to ~/.bashrc or ~/.zshrc
echo 'export FMP_API_KEY=your_api_key_here' >> ~/.bashrc
source ~/.bashrcMethod 2: Command-Line Argument
python3 scripts/screen_dividend_stocks.py --api-key your_api_key_hereKey Endpoints Used
1. Stock Screener
Endpoint: /v3/stock-screener
Purpose: Initial filtering by dividend yield, P/E, P/B, market cap
Parameters:
dividendYieldMoreThan: Minimum dividend yield (e.g., 3.5)priceEarningRatioLowerThan: Maximum P/E ratio (e.g., 20)priceToBookRatioLowerThan: Maximum P/B ratio (e.g., 2)marketCapMoreThan: Minimum market cap (e.g., 2000000000 = $2B)exchange: Exchanges to include (e.g., "NASDAQ,NYSE")limit: Max results (default: 1000)
Example Request:
https://financialmodelingprep.com/api/v3/stock-screener?
dividendYieldMoreThan=3.5&
priceEarningRatioLowerThan=20&
priceToBookRatioLowerThan=2&
marketCapMoreThan=2000000000&
exchange=NASDAQ,NYSE&
limit=1000&
apikey=YOUR_API_KEYResponse Format:
[
{
"symbol": "T",
"companyName": "AT&T Inc.",
"marketCap": 150000000000,
"sector": "Communication Services",
"industry": "Telecom Services",
"beta": 0.65,
"price": 20.50,
"lastAnnualDividend": 1.11,
"volume": 35000000,
"exchange": "NYSE",
"exchangeShortName": "NYSE",
"country": "US",
"isEtf": false,
"isActivelyTrading": true,
"dividendYield": 0.0541,
"pe": 7.5,
"priceToBook": 1.2
}
]2. Income Statement
Endpoint: /v3/income-statement/{symbol}
Purpose: Revenue, EPS, net income analysis
Parameters:
symbol: Stock ticker (e.g., "AAPL")limit: Number of periods (e.g., 5 for 5 years)period: "annual" or "quarter"
Example Request:
https://financialmodelingprep.com/api/v3/income-statement/AAPL?
limit=5&
apikey=YOUR_API_KEYKey Fields Used:
revenue: Total revenueeps: Earnings per sharenetIncome: Net incomedate: Fiscal period end date
3. Balance Sheet Statement
Endpoint: /v3/balance-sheet-statement/{symbol}
Purpose: Debt, equity, liquidity analysis
Parameters:
symbol: Stock tickerlimit: Number of periods
Key Fields Used:
totalDebt: Total debt (short-term + long-term)totalStockholdersEquity: Shareholders' equitytotalCurrentAssets: Current assetstotalCurrentLiabilities: Current liabilities
4. Cash Flow Statement
Endpoint: /v3/cash-flow-statement/{symbol}
Purpose: Free cash flow, dividends paid analysis
Parameters:
symbol: Stock tickerlimit: Number of periods
Key Fields Used:
operatingCashFlow: Cash from operationscapitalExpenditure: Capex (negative value)dividendsPaid: Dividends paid (negative value)freeCashFlow: OCF - Capex
5. Key Metrics
Endpoint: /v3/key-metrics/{symbol}
Purpose: ROE, ROA, and other quality metrics
Parameters:
symbol: Stock tickerlimit: Number of periods
Key Fields Used:
roe: Return on Equity (decimal, e.g., 0.15 = 15%)roa: Return on Assetsroic: Return on Invested CapitaldebtToEquity: Debt-to-equity ratiocurrentRatio: Current ratio
6. Historical Dividend
Endpoint: /v3/historical-price-full/stock_dividend/{symbol}
Purpose: Dividend history for growth rate calculation
Example Request:
https://financialmodelingprep.com/api/v3/historical-price-full/stock_dividend/AAPL?
apikey=YOUR_API_KEYResponse Format:
{
"symbol": "AAPL",
"historical": [
{
"date": "2024-11-08",
"label": "November 08, 24",
"adjDividend": 0.25,
"dividend": 0.25,
"recordDate": "2024-11-11",
"paymentDate": "2024-11-14",
"declarationDate": "2024-10-31"
}
]
}Rate Limiting Strategy
Built-in Protection
The screening script includes rate limiting:
- 0.3 second delay between requests (~3 requests/second)
- Automatic retry on 429 (rate limit exceeded) with 60-second backoff
- Timeout: 30 seconds per request
Managing Request Budget
For free tier (250 requests/day):
Requests per stock analyzed:
- Stock Screener: 1 request (returns 100-1000 stocks)
- Income Statement: 1 request per symbol
- Balance Sheet: 1 request per symbol
- Cash Flow: 1 request per symbol
- Key Metrics: 1 request per symbol
- Dividend History: 1 request per symbol
Total: 5 requests per symbol + 1 screener request
Budget allocation:
- Initial screener: 1 request
- Detailed analysis: 5 × N stocks = 5N requests
- Maximum stocks per run: (250 - 1) / 5 = ~49 stocks
Script default: Analyzes first 100 candidates but typically finds ~20-30 that pass all criteria
Best Practices
1. Run during off-peak hours: Lower chance of rate limits 2. Space out runs: Once daily or weekly, not multiple times per hour 3. Cache results: Save JSON output and analyze locally 4. Upgrade if needed: If screening large universes frequently, consider paid tier
Error Handling
Common Errors
1. Invalid API Key
{
"Error Message": "Invalid API KEY. Please retry or visit our documentation."
}Solution: Check API key, verify it's active in FMP dashboard
2. Rate Limit Exceeded (429)
{
"Error Message": "You have exceeded the rate limit. Please wait."
}Solution: Script automatically retries after 60 seconds
3. Symbol Not Found
{
"Error Message": "Invalid ticker symbol"
}Solution: Script skips symbol and continues (expected for delisted/invalid tickers)
4. Insufficient Data
- Empty array returned for financial statements
Solution: Script skips symbol (common for newly listed stocks or incomplete data)
Debugging
Check request count:
# Count API calls in script output
python3 scripts/screen_dividend_stocks.py 2>&1 | grep "Analyzing" | wc -lVerbose mode (add to script if needed):
import logging
logging.basicConfig(level=logging.DEBUG)Data Quality Considerations
Data Freshness
- Annual statements: Updated after fiscal year end (delays possible)
- Quarterly data: Available ~1-2 months after quarter end
- Real-time prices: Updated during market hours
- Dividend history: Updated after declaration/payment
Data Gaps
Some stocks may have:
- Incomplete history: < 4 years of data (newly public companies)
- Missing dividends: Not all dividend-paying stocks report via API
- Inconsistent metrics: Different accounting standards, restatements
Script behavior: Skips stocks with insufficient data (requires 4+ years)
Data Accuracy
FMP sources data from:
- SEC EDGAR filings (US companies)
- Company investor relations
- Exchange data feeds
Note: Always verify critical investment decisions with company filings (10-K, 10-Q)
API Documentation
Official Docs: https://financialmodelingprep.com/developer/docs
Key Sections:
- Stock Fundamentals API
- Stock Screener API
- Historical Dividend API
- Ratios API
Support: support@financialmodelingprep.com
Alternative Data Sources
If FMP limits are restrictive:
1. Alpha Vantage: Free tier, 500 requests/day 2. Yahoo Finance (yfinance): Free, unlimited (but less reliable) 3. Quandl/Nasdaq Data Link: Free tier available 4. IEX Cloud: Free tier, 50k messages/month
Note: Alternative sources may require script modifications for different data formats.
Value Dividend Stock Screening Methodology
Overview
This screening methodology identifies high-quality dividend stocks that combine:
- Value characteristics: Reasonable valuations (low P/E, P/B)
- Income generation: Attractive dividend yields (>=3.5%)
- Growth profile: Consistent dividend, revenue, and EPS growth
- Quality metrics: Strong profitability, financial health, and dividend sustainability
Screening Criteria
Phase 1: Initial Quantitative Filters
1. Dividend Yield >= 3.5%
Rationale: Provides meaningful income above typical market yields (S&P 500 average: ~1.5-2%)
Calculation:
Dividend Yield = (Annual Dividends per Share / Current Stock Price) × 100Threshold Logic:
- 3.5%+ provides attractive income
- Not so high as to signal dividend risk (>8% often unsustainable)
- Balances income and growth potential
2. P/E Ratio <= 20
Rationale: Identifies stocks trading at reasonable multiples relative to earnings
Calculation:
P/E Ratio = Market Price per Share / Earnings per Share (TTM)Threshold Logic:
- S&P 500 historical average: ~15-18x
- P/E <= 20 indicates value territory
- Excludes overvalued growth stocks
- Focuses on mature, profitable companies
3. P/B Ratio <= 2.0
Rationale: Ensures stock price is reasonable relative to book value
Calculation:
P/B Ratio = Market Price per Share / Book Value per ShareThreshold Logic:
- P/B <= 2.0 suggests reasonable valuation
- Avoids paying excessive premium over net assets
- Particularly relevant for asset-heavy businesses
Phase 2: Growth Quality Filters
4. Dividend Growth: 3-Year CAGR >= 5%
Rationale: Identifies companies with consistent dividend-raising track record
Calculation:
Dividend CAGR = [(End Dividend / Start Dividend)^(1/3) - 1] × 100Threshold Logic:
- 5% annual growth compounds meaningfully over time
- Demonstrates management confidence in cash flows
- Protects against inflation (long-term average: 2-3%)
- Signals business health and shareholder commitment
Consistency Check:
- No dividend cuts in the period
- Allows one year of flat dividends (economic cycles)
- Cuts signal financial stress or strategy changes
5. Revenue Growth: Positive 3-Year Trend
Rationale: Confirms top-line growth supports dividend sustainability
Evaluation:
- Revenue in Year 3 > Revenue in Year 1
- Allows one year of decline (cyclical businesses, one-time events)
- Overall upward trajectory required
Why Not a Fixed %:
- Different industries have different growth rates
- Mature dividend stocks may have modest but stable growth
- Focus is on trend direction rather than absolute rate
6. EPS Growth: Positive 3-Year Trend
Rationale: Ensures earnings power is expanding, not eroding
Evaluation:
- EPS in Year 3 > EPS in Year 1
- Allows one year of decline
- Overall upward trajectory required
Significance:
- Earnings fund dividends
- EPS growth = potential for future dividend increases
- Distinguishes quality companies from dividend traps
Phase 3: Quality & Sustainability Analysis
7. Dividend Sustainability Metrics
A. Payout Ratio
Payout Ratio = (Dividends Paid / Net Income) × 100Healthy Range: 30-70%
- < 30%: Conservative, room for growth
- 30-70%: Balanced, sustainable
- > 80%: Caution, limited flexibility
B. Free Cash Flow Payout Ratio
FCF Payout Ratio = (Dividends Paid / Free Cash Flow) × 100
where FCF = Operating Cash Flow - Capital ExpendituresHealthy Range: < 100%
- FCF is the true source of sustainable dividends
- < 100%: Dividends covered by actual cash generation
- > 100%: Unsustainable, funded by debt or asset sales
Sustainability Flag: ✅ if Payout Ratio < 80% AND FCF Payout Ratio < 100%
8. Financial Health Metrics
A. Debt-to-Equity Ratio
D/E Ratio = Total Debt / Shareholders' EquityHealthy Range: < 2.0
- Lower is generally better
- Varies by industry (utilities typically higher)
- < 2.0: Reasonable leverage, not overleveraged
B. Current Ratio
Current Ratio = Current Assets / Current LiabilitiesHealthy Range: > 1.0 (ideally > 1.5)
- > 1.0: Can cover short-term obligations
- > 1.5: Strong liquidity cushion
- < 1.0: Liquidity risk
Health Flag: ✅ if D/E < 2.0 AND Current Ratio > 1.0
9. Quality Score (0-100)
Components:
A. Return on Equity (ROE) - Max 50 points
ROE = Net Income / Shareholders' Equity
Points = min((ROE% / 20%) × 50, 50)- 20%+ ROE = 50 points (excellent capital efficiency)
- 10% ROE = 25 points (average)
- < 5% ROE = poor capital returns
B. Net Profit Margin - Max 50 points
Profit Margin = (Net Income / Revenue) × 100
Points = min((Margin% / 15%) × 50, 50)- 15%+ margin = 50 points (highly profitable)
- 7.5% margin = 25 points (average)
- < 3% margin = low profitability
Quality Score Interpretation:
- 80-100: Excellent quality (high profitability, efficiency)
- 60-79: Good quality
- 40-59: Average quality
- < 40: Below average quality
Composite Scoring System
Purpose
Rank stocks by overall attractiveness, balancing value, growth, and quality.
Score Components (Total: 100 points)
1. Dividend Growth (Max 20 points)
- 10%+ CAGR = 20 points
- 5% CAGR = 10 points
- Linear scaling
2. Revenue Growth (Max 15 points)
- 10%+ CAGR = 15 points
- 5% CAGR = 7.5 points
- Linear scaling
3. EPS Growth (Max 15 points)
- 15%+ CAGR = 15 points
- 7.5% CAGR = 7.5 points
- Linear scaling
4. Dividend Sustainability (10 points)
- Pass (sustainable) = 10 points
- Fail = 0 points
5. Financial Health (10 points)
- Pass (healthy) = 10 points
- Fail = 0 points
6. Quality Score (Max 30 points)
- Quality Score × 0.3
- 100 quality = 30 points
- 50 quality = 15 points
Interpretation
- 80-100: Exceptional (high growth, quality, sustainability)
- 60-79: Strong (solid all-around profile)
- 40-59: Good (meets criteria, some trade-offs)
- 20-39: Acceptable (passes filters but lower quality)
- < 20: Marginal (barely meets criteria)
Investment Philosophy
Why This Approach Works
1. Value + Growth + Quality: Combines three proven factor premiums 2. Dividend Focus: Signals management discipline and cash generation 3. Sustainability Screen: Avoids dividend traps and value traps 4. Growth Requirements: Ensures businesses are healthy, not declining 5. Quality Filters: Identifies durable competitive advantages
What This Strategy Avoids
1. Dividend Traps: High yields from struggling companies (growth filters catch these) 2. Value Traps: Cheap stocks that stay cheap (quality metrics catch these) 3. Overvaluation: Growth stocks trading at expensive multiples (P/E, P/B filters) 4. Financial Risk: Overleveraged or illiquid companies (health metrics)
Ideal Candidate Profile
A stock scoring highly in this screen typically:
- Operates in stable, mature industry
- Has sustainable competitive advantage (moat)
- Generates consistent free cash flow
- Committed to shareholder returns (dividends)
- Trades at reasonable valuation (not hyped)
- Growing modestly but consistently
- Strong balance sheet and profitability
Examples: Dividend Aristocrats, quality REITs (if included), stable utilities, consumer staples leaders
Usage Notes
Limitations
1. Market Cap Bias: Typically finds large/mid-cap stocks (small-caps less likely to meet all criteria) 2. Sector Bias: May overweight certain sectors (utilities, consumer staples, REITs) 3. Excludes High Growth: Tech and growth stocks generally won't qualify (by design) 4. Historical Performance: Past growth doesn't guarantee future results 5. Economic Sensitivity: Some qualified stocks may be cyclical
Best Practices
1. Diversification: Don't concentrate in top 5; spread across top 20 2. Sector Balance: Monitor sector exposure, avoid overconcentration 3. Rescreen Regularly: Quarterly or semi-annually; fundamentals change 4. Valuation Check: Just because it passed doesn't mean buy at any price 5. Dividend Safety: Monitor payout ratios and cash flows quarterly 6. Hold for Long Term: This is a quality dividend growth strategy, not trading
When to Sell
1. Dividend Cut: Immediate red flag; review business health 2. Deteriorating Fundamentals: Revenue/EPS declining multiple quarters 3. Payout Ratio > 100%: Dividend unsustainable 4. Debt Spike: Leverage increasing significantly without clear reason 5. Better Opportunities: Capital allocation to higher-scoring stocks 6. Valuation Extreme: Stock becomes significantly overvalued (P/E > 30, for example)
Historical Context
Why 3.5% Yield Threshold?
- US 10-Year Treasury: Historically 2-4%
- S&P 500 Dividend Yield: 1.5-2%
- Equity Risk Premium: 3.5% provides ~1.5-2% premium over Treasuries
- Tax Efficiency: Qualified dividends taxed favorably vs. bonds
Why P/E <= 20?
- S&P 500 Historical Average: ~15-18x
- Fair Value Range: 15-20x for mature, stable businesses
- Margin of Safety: Leaves room for multiple compression
- Cyclically Adjusted: Not overpaying at peak earnings
Why 5% Dividend CAGR?
- Inflation Protection: Beats long-term inflation (2-3%)
- Real Income Growth: Provides rising purchasing power
- Achievable: Sustainable for quality companies
- Compound Power: 5% doubles in 14.4 years
References
- Benjamin Graham: "The Intelligent Investor" (value investing principles)
- Jeremy Siegel: "The Future for Investors" (dividend growth research)
- CFA Institute: Equity Valuation standards
- S&P Dow Jones Indices: Dividend Aristocrats methodology
- Morningstar: Dividend Sustainability Research
#!/usr/bin/env python3
"""
Value Dividend Stock Screener using FINVIZ + Financial Modeling Prep API
Two-stage screening approach:
1. FINVIZ Elite API: Pre-screen stocks with basic criteria (fast, cost-effective)
2. FMP API: Detailed analysis of pre-screened candidates (comprehensive)
Screens US stocks based on:
- Dividend yield >= 3.5%
- P/E ratio <= 20
- P/B ratio <= 2
- Dividend CAGR >= 5% (3-year)
- Revenue growth: positive trend over 3 years
- EPS growth: positive trend over 3 years
- Additional analysis: dividend sustainability, financial health, quality scores
Outputs top 20 stocks ranked by composite score.
"""
import argparse
import csv
import io
import json
import os
import sys
import time
from datetime import datetime, timedelta
from typing import Optional
try:
import requests
except ImportError:
print("ERROR: requests library not found. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
class FINVIZClient:
"""Client for FINVIZ Elite API"""
BASE_URL = "https://elite.finviz.com/export.ashx"
def __init__(self, api_key: str):
self.api_key = api_key
self.session = requests.Session()
def screen_stocks(self) -> set[str]:
"""
Screen stocks using FINVIZ Elite API with predefined criteria
Criteria:
- Market cap: Mid-cap or higher
- Dividend yield: 3%+
- Dividend growth (3Y): 5%+
- EPS growth (3Y): Positive
- P/B: Under 2
- P/E: Under 20
- Sales growth (3Y): Positive
- Geography: USA
Returns:
Set of stock symbols
"""
# Build filter string in FINVIZ format: key_value,key_value,...
filters = "cap_midover,fa_div_o3,fa_divgrowth_3yo5,fa_eps3years_pos,fa_pb_u2,fa_pe_u20,fa_sales3years_pos,geo_usa"
params = {
"v": "151", # View type
"f": filters, # Filter conditions
"ft": "4", # File type: CSV export
"auth": self.api_key,
}
try:
print("Fetching pre-screened stocks from FINVIZ Elite API...", file=sys.stderr)
response = self.session.get(self.BASE_URL, params=params, timeout=30)
if response.status_code == 200:
# Parse CSV response
csv_content = response.content.decode("utf-8")
reader = csv.DictReader(io.StringIO(csv_content))
symbols = set()
for row in reader:
# FINVIZ CSV has 'Ticker' column
ticker = row.get("Ticker", "").strip()
if ticker:
symbols.add(ticker)
print(f"✅ FINVIZ returned {len(symbols)} pre-screened stocks", file=sys.stderr)
return symbols
elif response.status_code == 401 or response.status_code == 403:
print(
"ERROR: FINVIZ API authentication failed. Check your API key.", file=sys.stderr
)
print(f"Status code: {response.status_code}", file=sys.stderr)
return set()
else:
print(f"ERROR: FINVIZ API request failed: {response.status_code}", file=sys.stderr)
return set()
except requests.exceptions.RequestException as e:
print(f"ERROR: FINVIZ request exception: {e}", file=sys.stderr)
return set()
# --- FMP endpoint fallback: stable (new users) -> v3 (legacy users) ---
_FMP_HIST_ENDPOINTS = [
("https://financialmodelingprep.com/stable/historical-price-eod/full", True),
("https://financialmodelingprep.com/api/v3/historical-price-full", False),
]
class FMPClient:
"""Client for Financial Modeling Prep API"""
BASE_URL = "https://financialmodelingprep.com/api/v3"
STABLE_URL = "https://financialmodelingprep.com/stable"
_ENDPOINT_FAILURE_THRESHOLD = 3
# v3 path-style endpoints whose trailing symbol moves to a ?symbol= query
# on /stable (e.g. v3 income-statement/AAPL -> stable income-statement?symbol=AAPL)
_SYMBOL_QUERY_ENDPOINTS = (
"income-statement",
"balance-sheet-statement",
"cash-flow-statement",
"key-metrics",
"ratios",
"profile",
"quote",
)
def __init__(self, api_key: str):
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({"apikey": self.api_key})
self.rate_limit_reached = False
self.retry_count = 0
self._endpoint_failures: dict[str, int] = {}
self._disabled_endpoints: set[str] = set()
def _request(self, url: str, params: dict, quiet: bool = False) -> Optional[dict]:
"""Single rate-limited GET. Returns parsed JSON, or None on failure.
Non-final endpoints pass quiet=True to suppress the expected 403 a new
key gets on the v3 fallback.
"""
if self.rate_limit_reached:
return None
try:
response = self.session.get(url, params=params or {}, timeout=30)
time.sleep(0.3) # Rate limiting: ~3 requests/second
if response.status_code == 200:
self.retry_count = 0
return response.json()
elif response.status_code == 429:
self.retry_count += 1
if self.retry_count <= 1: # Only retry once
print("WARNING: Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr)
time.sleep(60)
return self._request(url, params, quiet=quiet)
print("ERROR: Daily API rate limit reached. Stopping analysis.", file=sys.stderr)
self.rate_limit_reached = True
return None
else:
if not quiet:
print(
f"ERROR: API request failed: {response.status_code} - {response.text}",
file=sys.stderr,
)
return None
except requests.exceptions.RequestException as e:
if not quiet:
print(f"ERROR: Request exception: {e}", file=sys.stderr)
return None
def _stable_spec(self, endpoint: str, params: dict) -> Optional[tuple]:
"""Map a v3 path-style endpoint to its (stable_url, stable_params).
Returns None when there is no known /stable equivalent.
"""
p = dict(params or {})
if endpoint == "stock-screener":
return f"{self.STABLE_URL}/company-screener", p
if endpoint.startswith("historical-price-full/stock_dividend/"):
p["symbol"] = endpoint.rsplit("/", 1)[-1]
return f"{self.STABLE_URL}/dividends", p
head, _, sym = endpoint.partition("/")
if sym and head in self._SYMBOL_QUERY_ENDPOINTS:
p["symbol"] = sym
return f"{self.STABLE_URL}/{head}", p
return None
@staticmethod
def _normalize(endpoint: str, data):
"""Reshape /stable responses to match the v3 shapes callers expect."""
if data is None:
return None
# Dividends: /stable returns a flat list; v3 returned {"historical": [...]}.
if endpoint.startswith("historical-price-full/stock_dividend/"):
return {"historical": data} if isinstance(data, list) else data
# key-metrics: /stable renamed roe -> returnOnEquity (same ratio scale).
if endpoint.startswith("key-metrics/") and isinstance(data, list):
for rec in data:
if isinstance(rec, dict) and "roe" not in rec and "returnOnEquity" in rec:
rec["roe"] = rec["returnOnEquity"]
# cash-flow: /stable renamed dividendsPaid -> netDividendsPaid (same
# negative-outflow value; callers take abs()).
if endpoint.startswith("cash-flow-statement/") and isinstance(data, list):
for rec in data:
if (
isinstance(rec, dict)
and "dividendsPaid" not in rec
and "netDividendsPaid" in rec
):
rec["dividendsPaid"] = rec["netDividendsPaid"]
return data
def _get(self, endpoint: str, params: Optional[dict] = None) -> Optional[dict]:
"""GET an FMP endpoint, preferring /stable with a v3 path-style fallback.
Callers still pass the legacy v3 path-style `endpoint` strings (e.g.
"income-statement/AAPL"); this routes them to the /stable query-style
equivalents and normalizes the response back to the v3 shape. Keys
issued after 2025-08-31 only work on /stable; legacy keys fall back to v3.
"""
if self.rate_limit_reached:
return None
params = params or {}
attempts = []
spec = self._stable_spec(endpoint, params)
if spec:
attempts.append(spec) # /stable first
attempts.append((f"{self.BASE_URL}/{endpoint}", dict(params))) # v3 fallback
for i, (url, req_params) in enumerate(attempts):
quiet = i < len(attempts) - 1
data = self._request(url, req_params, quiet=quiet)
if data is not None:
return self._normalize(endpoint, data)
return None
def screen_stocks(
self,
dividend_yield_min: float,
pe_max: float,
pb_max: float,
market_cap_min: float = 2_000_000_000,
max_candidates: int = 300,
) -> list[dict]:
"""Screen value-dividend candidates (dividend yield, P/E, P/B).
The retired v3 stock-screener filtered by dividend yield / P/E / P/B
server-side. /stable/company-screener cannot (it has no such params and
returns no yield/P/E/P/B fields), so the same three gates are applied
client-side, preserving the original criteria:
1. company-screener returns the market-cap + exchange universe.
2. A dividend-yield estimate (lastAnnualDividend / price) from the
screener payload pre-filters to >= dividend_yield_min — free, no
extra calls. ETFs / funds are dropped (no income statement to
analyze downstream).
3. The largest ``max_candidates`` (by market cap) are checked against
/stable/ratios for priceToEarningsRatio <= pe_max and
priceToBookRatio <= pb_max. The cap bounds API usage (one ratios
call per candidate); raise it for broader coverage.
Note: candidates are prioritized by market cap, not yield. Sorting by
the estimated yield surfaces distorted values (special distributions /
stale data on illiquid or delisted names produce absurd "yields"),
whereas market cap surfaces the liquid quality payers the screen targets.
"""
universe = (
self._get(
"stock-screener",
{
"marketCapMoreThan": market_cap_min,
"exchange": "NASDAQ,NYSE",
"limit": 10000,
},
)
or []
)
# Gate 1: dividend yield, estimated from screener fields (no extra calls).
yield_candidates = []
for item in universe:
if item.get("isEtf") or item.get("isFund"):
continue # not an operating company; no statements to analyze
price = item.get("price") or 0
last_div = item.get("lastAnnualDividend") or 0
if price <= 0:
continue
est_yield = (last_div / price) * 100
if est_yield >= dividend_yield_min:
item = dict(item)
item["_est_yield"] = est_yield
yield_candidates.append(item)
# Prioritize the largest dividend payers (liquid, with price history)
# and cap to bound the per-candidate ratios calls.
yield_candidates.sort(key=lambda s: s.get("marketCap") or 0, reverse=True)
yield_candidates = yield_candidates[:max_candidates]
# Gate 2 + 3: P/E and P/B via /stable/ratios (one call per candidate).
survivors = []
for item in yield_candidates:
if self.rate_limit_reached:
break
symbol = item.get("symbol")
if not symbol:
continue
ratios = self._get(f"ratios/{symbol}", {"limit": 1})
if not (isinstance(ratios, list) and ratios):
continue
pe = ratios[0].get("priceToEarningsRatio")
pb = ratios[0].get("priceToBookRatio")
if pe is None or pb is None:
continue
if 0 < pe <= pe_max and 0 < pb <= pb_max:
item["pe"] = pe
item["priceToBook"] = pb
survivors.append(item)
return survivors
def get_income_statement(self, symbol: str, limit: int = 5) -> list[dict]:
"""Get income statement"""
return self._get(f"income-statement/{symbol}", {"limit": limit}) or []
def get_balance_sheet(self, symbol: str, limit: int = 5) -> list[dict]:
"""Get balance sheet"""
return self._get(f"balance-sheet-statement/{symbol}", {"limit": limit}) or []
def get_cash_flow(self, symbol: str, limit: int = 5) -> list[dict]:
"""Get cash flow statement"""
return self._get(f"cash-flow-statement/{symbol}", {"limit": limit}) or []
def get_key_metrics(self, symbol: str, limit: int = 5) -> list[dict]:
"""Get key metrics"""
return self._get(f"key-metrics/{symbol}", {"limit": limit}) or []
def get_dividend_history(self, symbol: str) -> list[dict]:
"""Get dividend history"""
return self._get(f"historical-price-full/stock_dividend/{symbol}") or {}
def get_company_profile(self, symbol: str) -> Optional[dict]:
"""Get company profile including sector information."""
result = self._get(f"profile/{symbol}")
if result and isinstance(result, list) and len(result) > 0:
return result[0]
return None
def get_historical_prices(self, symbol: str, days: int = 30) -> list[dict]:
"""Get historical daily prices, most-recent-first (/stable, v3 fallback).
/stable/historical-price-eod/full returns a flat list of OHLCV bars and
is bounded with from/to; the v3 fallback returns {"historical": [...]}.
"""
for base_url, is_stable in _FMP_HIST_ENDPOINTS:
if base_url in self._disabled_endpoints:
continue
if is_stable:
url = base_url
today = datetime.now().date()
params = {
"symbol": symbol,
# ~days trading days needs ~2x calendar days; +10 for slack.
"from": (today - timedelta(days=days * 2 + 10)).isoformat(),
"to": today.isoformat(),
}
else:
url = f"{base_url}/{symbol}"
params = {"serietype": "line"}
try:
response = self.session.get(url, params=params, timeout=30)
time.sleep(0.3)
if response.status_code != 200:
self._record_endpoint_failure(base_url)
continue
data = response.json()
# /stable: flat list of bars (most-recent-first).
if isinstance(data, list):
if data:
self._endpoint_failures[base_url] = 0
return data[:days]
self._record_endpoint_failure(base_url)
continue
# v3: {"symbol": ..., "historical": [...]}.
if isinstance(data, dict) and "historical" in data:
self._endpoint_failures[base_url] = 0
return data["historical"][:days]
if isinstance(data, dict) and "historicalStockList" in data:
for entry in data["historicalStockList"]:
if entry.get("symbol", "").replace("-", ".") == symbol.replace("-", "."):
self._endpoint_failures[base_url] = 0
return entry.get("historical", [])[:days]
self._record_endpoint_failure(base_url)
except Exception:
self._record_endpoint_failure(base_url)
return []
def _record_endpoint_failure(self, base_url: str) -> None:
"""Track consecutive failures and disable endpoint after threshold."""
failures = self._endpoint_failures.get(base_url, 0) + 1
self._endpoint_failures[base_url] = failures
if failures >= self._ENDPOINT_FAILURE_THRESHOLD:
self._disabled_endpoints.add(base_url)
class RSICalculator:
"""Calculate RSI (Relative Strength Index)"""
@staticmethod
def calculate_rsi(prices: list[float], period: int = 14) -> Optional[float]:
"""
Calculate RSI from price data.
Args:
prices: List of closing prices (oldest to newest)
period: RSI period (default 14)
Returns:
RSI value (0-100) or None if insufficient data
"""
if len(prices) < period + 1:
return None
# Calculate price changes
changes = [prices[i] - prices[i - 1] for i in range(1, len(prices))]
# Separate gains and losses
gains = [max(0, change) for change in changes]
losses = [abs(min(0, change)) for change in changes]
# Calculate initial average gain/loss
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
# Smooth using Wilder's method for remaining periods
for i in range(period, len(changes)):
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
# Calculate RSI
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return round(rsi, 1)
class StockAnalyzer:
"""Analyzes stock data and calculates scores"""
@staticmethod
def is_reit(stock_data: dict) -> bool:
"""
Determine if a stock is a REIT based on sector/industry.
Args:
stock_data: Dict containing sector and/or industry fields
Returns:
True if the stock is likely a REIT
"""
sector = stock_data.get("sector", "") or ""
industry = stock_data.get("industry", "") or ""
sector_lower = sector.lower()
industry_lower = industry.lower()
if "real estate" in sector_lower:
return True
if "reit" in industry_lower:
return True
return False
@staticmethod
def calculate_ffo(cash_flows: list[dict]) -> Optional[float]:
"""
Calculate Funds From Operations (FFO) for REITs.
FFO = Net Income + Depreciation & Amortization
Args:
cash_flows: List of cash flow statements (newest first)
Returns:
FFO value or None if data is missing
"""
if not cash_flows:
return None
latest_cf = cash_flows[0]
net_income = latest_cf.get("netIncome", 0)
depreciation = latest_cf.get("depreciationAndAmortization", 0)
if net_income == 0 and depreciation == 0:
return None
return net_income + depreciation
@staticmethod
def calculate_ffo_payout_ratio(cash_flows: list[dict]) -> Optional[float]:
"""
Calculate FFO payout ratio for REITs.
FFO Payout Ratio = Dividends Paid / FFO
Args:
cash_flows: List of cash flow statements (newest first)
Returns:
FFO payout ratio as percentage, or None if calculation fails
"""
if not cash_flows:
return None
ffo = StockAnalyzer.calculate_ffo(cash_flows)
if not ffo or ffo <= 0:
return None
latest_cf = cash_flows[0]
dividends_paid = abs(latest_cf.get("dividendsPaid", 0))
if dividends_paid <= 0:
return None
return round((dividends_paid / ffo) * 100, 1)
@staticmethod
def calculate_cagr(start_value: float, end_value: float, years: int) -> Optional[float]:
"""Calculate Compound Annual Growth Rate"""
if start_value <= 0 or end_value <= 0 or years <= 0:
return None
return (pow(end_value / start_value, 1 / years) - 1) * 100
@staticmethod
def check_positive_trend(values: list[float]) -> bool:
"""Check if values show positive trend (允许一次回落)"""
if len(values) < 3:
return False
# Check overall trend: first < last
if values[0] >= values[-1]:
return False
# Allow one dip but overall upward trend
dips = sum(1 for i in range(1, len(values)) if values[i] < values[i - 1])
return dips <= 1
@staticmethod
def analyze_dividend_growth(
dividend_history: list[dict],
years_back: int = 3,
as_of=None,
) -> tuple[Optional[float], bool, Optional[float]]:
"""Dividend growth (CAGR + consistency) on a trailing-twelve-month basis.
Summing calendar years distorts the result mid-year (the current year is
incomplete) and lags a recent raise until year-end. Per standard
practice (e.g. Stockopedia / Simply Wall St measure DPS growth on a TTM
basis), dividends are summed over rolling 12-month windows: the current
TTM vs the TTM ending ``years_back`` years earlier. Future-dated
declarations (announced but not yet paid) are ignored. Returns
``(cagr_pct, consistent, latest_ttm_dividend)``.
"""
if not dividend_history or "historical" not in dividend_history:
return None, False, None
from datetime import date as _date
today = as_of or _date.today()
# Parse (date, amount); drop undated, non-positive, and future-dated.
parsed = []
for div in dividend_history["historical"]:
ds = div.get("date")
amt = div.get("dividend") or 0
if not ds or amt <= 0:
continue
try:
d = _date.fromisoformat(str(ds)[:10])
except ValueError:
continue
if d <= today:
parsed.append((d, amt))
if not parsed:
return None, False, None
# Need ~years_back+1 years of history for the oldest TTM window.
oldest = min(d for d, _ in parsed)
if (today - oldest).days < 365 * years_back:
return None, False, None
def ttm(end):
"""Sum of dividends in the 365 days ending at ``end``."""
start = end - timedelta(days=365)
return sum(a for d, a in parsed if start < d <= end)
# Rolling-12m windows from years_back ago (oldest) to today (newest).
windows = [ttm(today - timedelta(days=365 * k)) for k in range(years_back, -1, -1)]
if windows[0] <= 0 or windows[-1] <= 0:
return None, False, None
cagr = StockAnalyzer.calculate_cagr(windows[0], windows[-1], years_back)
# Consistency: each year's TTM >= 95% of the prior year's (no cuts).
consistent = all(windows[i] >= windows[i - 1] * 0.95 for i in range(1, len(windows)))
latest_annual_dividend = windows[-1]
return cagr, consistent, latest_annual_dividend
@staticmethod
def analyze_revenue_growth(income_statements: list[dict]) -> tuple[bool, Optional[float]]:
"""Analyze revenue growth trend"""
if len(income_statements) < 4:
return False, None
revenues = [stmt.get("revenue", 0) for stmt in income_statements[:4]]
revenues.reverse() # Oldest to newest
positive_trend = StockAnalyzer.check_positive_trend(revenues)
cagr = (
StockAnalyzer.calculate_cagr(revenues[0], revenues[-1], 3) if revenues[0] > 0 else None
)
return positive_trend, cagr
@staticmethod
def analyze_eps_growth(income_statements: list[dict]) -> tuple[bool, Optional[float]]:
"""Analyze EPS growth trend"""
if len(income_statements) < 4:
return False, None
eps_values = [stmt.get("eps", 0) for stmt in income_statements[:4]]
eps_values.reverse() # Oldest to newest
positive_trend = StockAnalyzer.check_positive_trend(eps_values)
cagr = (
StockAnalyzer.calculate_cagr(eps_values[0], eps_values[-1], 3)
if eps_values[0] > 0
else None
)
return positive_trend, cagr
@staticmethod
def analyze_dividend_sustainability(
income_statements: list[dict], cash_flows: list[dict], is_reit: bool = False
) -> dict:
"""
Analyze dividend sustainability.
For REITs, uses FFO-based payout ratio instead of net income-based.
Args:
income_statements: List of income statements (newest first)
cash_flows: List of cash flow statements (newest first)
is_reit: Whether the stock is a REIT (uses FFO for payout calculation)
Returns:
Dict with payout_ratio, fcf_payout_ratio, and sustainable flag
"""
result = {"payout_ratio": None, "fcf_payout_ratio": None, "sustainable": False}
if not cash_flows:
return result
latest_cf = cash_flows[0]
dividends_paid = abs(latest_cf.get("dividendsPaid", 0))
# For REITs, use FFO-based payout ratio
if is_reit:
ffo_payout = StockAnalyzer.calculate_ffo_payout_ratio(cash_flows)
if ffo_payout is not None:
result["payout_ratio"] = ffo_payout
else:
# For non-REITs, use traditional net income-based payout ratio
if income_statements:
latest_income = income_statements[0]
net_income = latest_income.get("netIncome", 0)
if net_income > 0 and dividends_paid > 0:
result["payout_ratio"] = (dividends_paid / net_income) * 100
# FCF payout ratio (same for both REIT and non-REIT)
operating_cf = latest_cf.get("operatingCashFlow", 0)
capex = abs(latest_cf.get("capitalExpenditure", 0))
fcf = operating_cf - capex
if fcf > 0 and dividends_paid > 0:
result["fcf_payout_ratio"] = (dividends_paid / fcf) * 100
# Sustainable if payout ratio < 80% and FCF covers dividends
if result["payout_ratio"] and result["fcf_payout_ratio"]:
result["sustainable"] = result["payout_ratio"] < 80 and result["fcf_payout_ratio"] < 100
elif result["payout_ratio"]:
# If FCF payout is not available, just check payout ratio
result["sustainable"] = result["payout_ratio"] < 80
return result
@staticmethod
def analyze_financial_health(balance_sheets: list[dict]) -> dict:
"""Analyze financial health metrics"""
result = {"debt_to_equity": None, "current_ratio": None, "healthy": False}
if not balance_sheets:
return result
latest_bs = balance_sheets[0]
# Debt-to-Equity ratio
total_debt = latest_bs.get("totalDebt", 0)
shareholders_equity = latest_bs.get("totalStockholdersEquity", 0)
if shareholders_equity > 0:
result["debt_to_equity"] = total_debt / shareholders_equity
# Current ratio
current_assets = latest_bs.get("totalCurrentAssets", 0)
current_liabilities = latest_bs.get("totalCurrentLiabilities", 0)
if current_liabilities > 0:
result["current_ratio"] = current_assets / current_liabilities
# Healthy if D/E < 2.0 and Current Ratio > 1.0
if result["debt_to_equity"] is not None and result["current_ratio"] is not None:
result["healthy"] = result["debt_to_equity"] < 2.0 and result["current_ratio"] > 1.0
return result
@staticmethod
def analyze_dividend_stability(dividend_history: dict, as_of=None) -> dict:
"""
Analyze dividend stability and growth consistency.
Evaluates:
- Year-over-year dividend growth
- Volatility (variation in annual dividends)
- Consecutive years of growth
Args:
dividend_history: Dict with 'historical' key containing dividend records
Returns:
Dict with is_stable, is_growing, volatility_pct, years_of_growth
"""
result = {
"is_stable": False,
"is_growing": False,
"volatility_pct": None,
"years_of_growth": 0,
"annual_dividends": {},
}
if not dividend_history or "historical" not in dividend_history:
return result
dividends = dividend_history["historical"]
if len(dividends) < 4:
return result
from datetime import date as _date
current_year = (as_of or _date.today()).year
# Calculate annual dividends over COMPLETE calendar years only. The
# current year is still in progress (and /stable also lists future-dated
# declarations), so including it understates the latest year and
# manufactures volatility / fake "cut" signals.
annual_dividends = {}
for div in dividends:
year = div.get("date", "")[:4]
if year and year.isdigit() and int(year) < current_year:
annual_dividends[year] = annual_dividends.get(year, 0) + (div.get("dividend") or 0)
if len(annual_dividends) < 3:
return result
result["annual_dividends"] = annual_dividends
# Get sorted years (newest to oldest for analysis)
years = sorted(annual_dividends.keys(), reverse=True)
div_values = [annual_dividends[y] for y in years]
# Calculate volatility (coefficient of variation)
if len(div_values) >= 2:
avg_div = sum(div_values) / len(div_values)
if avg_div > 0:
max_div = max(div_values)
min_div = min(div_values)
# Volatility as percentage variation from average
volatility = ((max_div - min_div) / avg_div) * 100
result["volatility_pct"] = round(volatility, 1)
# Stable if volatility < 50% (allowing some variation)
result["is_stable"] = volatility < 50
# Count consecutive years of growth (from oldest to newest)
years_oldest_first = sorted(annual_dividends.keys())
div_values_oldest_first = [annual_dividends[y] for y in years_oldest_first]
years_of_growth = 0
for i in range(1, len(div_values_oldest_first)):
# Allow small decrease (5%) as "no cut"
if div_values_oldest_first[i] >= div_values_oldest_first[i - 1] * 0.95:
years_of_growth += 1
else:
years_of_growth = 0 # Reset on dividend cut
result["years_of_growth"] = years_of_growth
# Growing if at least 2 consecutive years of growth and overall uptrend
if len(div_values_oldest_first) >= 3:
overall_growth = div_values_oldest_first[-1] > div_values_oldest_first[0]
result["is_growing"] = years_of_growth >= 2 and overall_growth
return result
@staticmethod
def analyze_revenue_trend(income_statements: list[dict]) -> dict:
"""
Analyze revenue trend for year-over-year growth.
Args:
income_statements: List of income statements (newest first)
Returns:
Dict with is_uptrend, years_of_growth, cagr
"""
result = {"is_uptrend": False, "years_of_growth": 0, "cagr": None}
if len(income_statements) < 3:
return result
# Get revenues (newest first in input, reverse for analysis)
revenues = [stmt.get("revenue", 0) for stmt in income_statements[:4]]
revenues_oldest_first = list(reversed(revenues))
# Count years of growth
years_of_growth = 0
for i in range(1, len(revenues_oldest_first)):
if revenues_oldest_first[i] > revenues_oldest_first[i - 1]:
years_of_growth += 1
else:
# Allow one dip but don't reset completely
pass
result["years_of_growth"] = years_of_growth
# Calculate CAGR
if revenues_oldest_first[0] > 0 and revenues_oldest_first[-1] > 0:
years = len(revenues_oldest_first) - 1
if years > 0:
cagr = (
pow(revenues_oldest_first[-1] / revenues_oldest_first[0], 1 / years) - 1
) * 100
result["cagr"] = round(cagr, 2)
# Uptrend if overall growth and at least 2 years of growth
overall_growth = revenues_oldest_first[-1] > revenues_oldest_first[0]
result["is_uptrend"] = overall_growth and years_of_growth >= 2
return result
@staticmethod
def analyze_earnings_trend(income_statements: list[dict]) -> dict:
"""
Analyze earnings/profit trend for year-over-year growth.
Args:
income_statements: List of income statements (newest first)
Returns:
Dict with is_uptrend, years_of_growth, cagr
"""
result = {"is_uptrend": False, "years_of_growth": 0, "cagr": None}
if len(income_statements) < 3:
return result
# Get net income (newest first in input, reverse for analysis)
earnings = [stmt.get("netIncome", 0) for stmt in income_statements[:4]]
earnings_oldest_first = list(reversed(earnings))
# Check for negative earnings (not a good sign)
if any(e <= 0 for e in earnings_oldest_first):
return result
# Count years of growth
years_of_growth = 0
for i in range(1, len(earnings_oldest_first)):
if earnings_oldest_first[i] > earnings_oldest_first[i - 1]:
years_of_growth += 1
result["years_of_growth"] = years_of_growth
# Calculate CAGR
if earnings_oldest_first[0] > 0 and earnings_oldest_first[-1] > 0:
years = len(earnings_oldest_first) - 1
if years > 0:
cagr = (
pow(earnings_oldest_first[-1] / earnings_oldest_first[0], 1 / years) - 1
) * 100
result["cagr"] = round(cagr, 2)
# Uptrend if overall growth and at least 2 years of growth
overall_growth = earnings_oldest_first[-1] > earnings_oldest_first[0]
result["is_uptrend"] = overall_growth and years_of_growth >= 2
return result
@staticmethod
def calculate_stability_score(stability: dict) -> float:
"""
Calculate a stability score based on dividend stability metrics.
Args:
stability: Dict from analyze_dividend_stability
Returns:
Score from 0-100
"""
score = 0
# Stability bonus (max 40 points)
if stability.get("is_stable"):
score += 40
elif stability.get("volatility_pct") is not None:
# Partial credit for lower volatility
volatility = stability["volatility_pct"]
if volatility < 100:
score += max(0, 40 - (volatility * 0.4))
# Growth bonus (max 30 points)
if stability.get("is_growing"):
score += 30
# Years of growth bonus (max 30 points, 10 per year)
years = stability.get("years_of_growth", 0)
score += min(years * 10, 30)
return round(score, 1)
@staticmethod
def calculate_quality_score(key_metrics: list[dict], income_statements: list[dict]) -> dict:
"""Calculate quality scores (ROE, Profit Margin)"""
result = {"roe": None, "profit_margin": None, "quality_score": 0}
if not key_metrics or not income_statements:
return result
# ROE (Return on Equity)
latest_metrics = key_metrics[0]
result["roe"] = latest_metrics.get("roe")
# Profit Margin
latest_income = income_statements[0]
revenue = latest_income.get("revenue", 0)
net_income = latest_income.get("netIncome", 0)
if revenue > 0:
result["profit_margin"] = (net_income / revenue) * 100
# Quality score (0-100)
score = 0
if result["roe"]:
roe_pct = result["roe"] * 100
score += min(roe_pct / 20 * 50, 50) # Max 50 points for 20%+ ROE
if result["profit_margin"]:
score += min(result["profit_margin"] / 15 * 50, 50) # Max 50 points for 15%+ margin
result["quality_score"] = round(score, 1)
return result
def screen_value_dividend_stocks(
fmp_api_key: str,
top_n: int = 20,
finviz_symbols: Optional[set[str]] = None,
max_candidates: int = 300,
) -> list[dict]:
"""
Main screening function
Args:
fmp_api_key: Financial Modeling Prep API key
top_n: Number of top stocks to return
finviz_symbols: Optional set of symbols from FINVIZ pre-screening
Returns:
List of stocks with detailed analysis, sorted by composite score
"""
client = FMPClient(fmp_api_key)
analyzer = StockAnalyzer()
rsi_calc = RSICalculator()
# Step 1: Get candidate list
if finviz_symbols:
print(
f"Step 1: Using FINVIZ pre-screened symbols ({len(finviz_symbols)} stocks)...",
file=sys.stderr,
)
# Convert FINVIZ symbols to candidate format for FMP analysis
# Fetch quote + profile to get sector information
candidates = []
print("Fetching quote and profile data from FMP for FINVIZ symbols...", file=sys.stderr)
for symbol in finviz_symbols:
quote = client._get(f"quote/{symbol}")
if quote and isinstance(quote, list) and len(quote) > 0:
stock_data = quote[0].copy()
# Fetch profile for sector information
profile = client.get_company_profile(symbol)
if profile:
stock_data["sector"] = profile.get("sector", "N/A")
stock_data["industry"] = profile.get("industry", "")
stock_data["companyName"] = profile.get(
"companyName", stock_data.get("name", "")
)
# /stable quote omits P/E and P/B (v3 quote carried them); pull
# them from /stable/ratios so the report's pe_ratio / pb_ratio
# stay populated for FINVIZ-sourced candidates too.
ratios = client._get(f"ratios/{symbol}", {"limit": 1})
if isinstance(ratios, list) and ratios:
stock_data["pe"] = ratios[0].get("priceToEarningsRatio")
stock_data["priceToBook"] = ratios[0].get("priceToBookRatio")
candidates.append(stock_data)
if client.rate_limit_reached:
print(
f"⚠️ FMP rate limit reached while fetching quotes. Using {len(candidates)} symbols.",
file=sys.stderr,
)
break
print(
f"Retrieved quote and profile data for {len(candidates)} symbols from FMP",
file=sys.stderr,
)
else:
print(
"Step 1: Initial screening using FMP Stock Screener (Dividend Yield >= 3.0%, P/E <= 20, P/B <= 2)...",
file=sys.stderr,
)
print("Criteria: Div Yield >= 3.0%, Div Growth >= 4.0% CAGR", file=sys.stderr)
candidates = client.screen_stocks(
dividend_yield_min=3.0, pe_max=20, pb_max=2, max_candidates=max_candidates
)
print(f"Found {len(candidates)} initial candidates", file=sys.stderr)
if not candidates:
print("No stocks found matching initial criteria", file=sys.stderr)
return []
results = []
print("\nStep 2: Detailed analysis of candidates...", file=sys.stderr)
print("Note: Analysis will continue until API rate limit is reached", file=sys.stderr)
for i, stock in enumerate(candidates, 1): # Analyze all candidates until rate limit
symbol = stock.get("symbol", "")
company_name = stock.get("name", stock.get("companyName", ""))
print(f"[{i}/{len(candidates)}] Analyzing {symbol} - {company_name}...", file=sys.stderr)
# Check if rate limit reached
if client.rate_limit_reached:
print(f"\n⚠️ API rate limit reached after analyzing {i - 1} stocks.", file=sys.stderr)
print(
f"Returning results collected so far: {len(results)} qualified stocks",
file=sys.stderr,
)
break
# Fetch detailed data
income_stmts = client.get_income_statement(symbol, limit=5)
if client.rate_limit_reached:
break
balance_sheets = client.get_balance_sheet(symbol, limit=5)
if client.rate_limit_reached:
break
cash_flows = client.get_cash_flow(symbol, limit=5)
if client.rate_limit_reached:
break
key_metrics = client.get_key_metrics(symbol, limit=5)
if client.rate_limit_reached:
break
dividend_history = client.get_dividend_history(symbol)
if client.rate_limit_reached:
break
# Fetch historical prices for RSI calculation
historical_prices = client.get_historical_prices(symbol, days=30)
if client.rate_limit_reached:
break
# Calculate RSI for technical analysis
rsi = None
if historical_prices and len(historical_prices) >= 20:
# Prices come newest first, reverse for RSI calculation
prices = [p.get("close", 0) for p in reversed(historical_prices)]
rsi = rsi_calc.calculate_rsi(prices, period=14)
if rsi is None:
print(" ⚠️ RSI calculation failed (insufficient price data)", file=sys.stderr)
continue
# Store RSI for later filtering
stock["_rsi"] = rsi
# Fetch profile for sector information if not already present
if not stock.get("sector") or stock.get("sector") == "N/A":
profile = client.get_company_profile(symbol)
if profile:
stock["sector"] = profile.get("sector", "N/A")
stock["industry"] = profile.get("industry", "")
if client.rate_limit_reached:
break
# Skip if insufficient data
if len(income_stmts) < 4:
print(" ⚠️ Insufficient income statement data", file=sys.stderr)
continue
# Analyze dividend growth and get latest annual dividend
div_cagr, div_consistent, annual_dividend = analyzer.analyze_dividend_growth(
dividend_history
)
if not div_cagr or div_cagr < 4.0:
print(" ⚠️ Dividend CAGR < 4% (or no data)", file=sys.stderr)
continue
# Calculate actual dividend yield
current_price = stock.get("price", 0)
if current_price <= 0 or not annual_dividend:
print(
" ⚠️ Cannot calculate dividend yield (price or dividend data missing)",
file=sys.stderr,
)
continue
actual_dividend_yield = (annual_dividend / current_price) * 100
# Verify dividend yield >= 3.0%
if actual_dividend_yield < 3.0:
print(f" ⚠️ Dividend yield {actual_dividend_yield:.2f}% < 3.0%", file=sys.stderr)
continue
# Analyze revenue growth
revenue_positive, revenue_cagr = analyzer.analyze_revenue_growth(income_stmts)
if not revenue_positive:
print(" ⚠️ Revenue trend not positive", file=sys.stderr)
continue
# Analyze EPS growth
eps_positive, eps_cagr = analyzer.analyze_eps_growth(income_stmts)
if not eps_positive:
print(" ⚠️ EPS trend not positive", file=sys.stderr)
continue
# NEW: Check dividend stability - filter out highly volatile dividends
dividend_stability = analyzer.analyze_dividend_stability(dividend_history)
if dividend_stability["volatility_pct"] and dividend_stability["volatility_pct"] > 100:
# Allow if consistently growing despite volatility
if not dividend_stability["is_growing"] or dividend_stability["years_of_growth"] < 3:
print(
f" ⚠️ Dividend too volatile ({dividend_stability['volatility_pct']:.1f}%) and not consistently growing",
file=sys.stderr,
)
continue
# Check if this is a REIT (uses different payout ratio calculation)
is_reit = analyzer.is_reit(stock)
# Additional analysis
sustainability = analyzer.analyze_dividend_sustainability(
income_stmts, cash_flows, is_reit=is_reit
)
financial_health = analyzer.analyze_financial_health(balance_sheets)
quality = analyzer.calculate_quality_score(key_metrics, income_stmts)
# Calculate stability score (dividend_stability already analyzed above)
stability_score = analyzer.calculate_stability_score(dividend_stability)
# NEW: Analyze revenue and earnings trends
revenue_trend = analyzer.analyze_revenue_trend(income_stmts)
earnings_trend = analyzer.analyze_earnings_trend(income_stmts)
# Calculate composite score (updated to include stability)
composite_score = 0
composite_score += min(div_cagr / 10 * 15, 15) # Max 15 points for 10%+ div growth
composite_score += stability_score * 0.2 # Max 20 points from stability (100 * 0.2)
composite_score += min((revenue_cagr or 0) / 10 * 10, 10) # Max 10 points for revenue
composite_score += min((eps_cagr or 0) / 15 * 10, 10) # Max 10 points for EPS
composite_score += 10 if sustainability["sustainable"] else 0
composite_score += 10 if financial_health["healthy"] else 0
composite_score += quality["quality_score"] * 0.25 # Max 25 points from quality
result = {
"symbol": symbol,
"company_name": company_name,
"sector": stock.get("sector", "N/A"),
"market_cap": stock.get("marketCap", 0),
"price": stock.get("price", 0),
"dividend_yield": round(actual_dividend_yield, 2),
"annual_dividend": round(annual_dividend, 2),
"pe_ratio": stock.get("pe", 0),
"pb_ratio": stock.get("priceToBook", 0),
"rsi": rsi,
"dividend_cagr_3y": round(div_cagr, 2),
"dividend_consistent": div_consistent,
"dividend_stable": dividend_stability["is_stable"],
"dividend_growing": dividend_stability["is_growing"],
"dividend_volatility_pct": dividend_stability["volatility_pct"],
"dividend_years_of_growth": dividend_stability["years_of_growth"],
"revenue_cagr_3y": round(revenue_cagr, 2) if revenue_cagr else None,
"revenue_uptrend": revenue_trend["is_uptrend"],
"revenue_years_of_growth": revenue_trend["years_of_growth"],
"eps_cagr_3y": round(eps_cagr, 2) if eps_cagr else None,
"earnings_uptrend": earnings_trend["is_uptrend"],
"earnings_years_of_growth": earnings_trend["years_of_growth"],
"payout_ratio": round(sustainability["payout_ratio"], 1)
if sustainability["payout_ratio"]
else None,
"fcf_payout_ratio": round(sustainability["fcf_payout_ratio"], 1)
if sustainability["fcf_payout_ratio"]
else None,
"dividend_sustainable": sustainability["sustainable"],
"debt_to_equity": round(financial_health["debt_to_equity"], 2)
if financial_health["debt_to_equity"]
else None,
"current_ratio": round(financial_health["current_ratio"], 2)
if financial_health["current_ratio"]
else None,
"financially_healthy": financial_health["healthy"],
"roe": round(key_metrics[0].get("roe", 0) * 100, 1) if key_metrics else None,
"profit_margin": round(quality["profit_margin"], 1)
if quality["profit_margin"]
else None,
"quality_score": quality["quality_score"],
"stability_score": stability_score,
"composite_score": round(composite_score, 1),
}
results.append(result)
print(
f" ✅ Passed all criteria (RSI: {rsi:.1f}, Score: {result['composite_score']})",
file=sys.stderr,
)
# Step 3: Filter by RSI
# Prefer RSI <= 40 (oversold), but if none found, return lowest RSI stocks
oversold_results = [r for r in results if r["rsi"] <= 40]
if oversold_results:
print(
f"\nStep 3: Found {len(oversold_results)} oversold stocks (RSI <= 40)", file=sys.stderr
)
# Sort oversold stocks by composite score
oversold_results.sort(key=lambda x: x["composite_score"], reverse=True)
results = oversold_results[:top_n]
else:
print(
"\nStep 3: No oversold stocks found (RSI <= 40). Returning lowest RSI stocks.",
file=sys.stderr,
)
# Sort by RSI (lowest first), then by composite score
results.sort(key=lambda x: (x["rsi"], -x["composite_score"]))
results = results[:top_n]
return results
def main():
parser = argparse.ArgumentParser(
description="Screen value dividend stocks using FINVIZ + FMP API (two-stage approach)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Two-stage screening: FINVIZ pre-screen + FMP detailed analysis (RECOMMENDED)
python3 screen_dividend_stocks.py --use-finviz
# FMP-only screening (original method)
python3 screen_dividend_stocks.py
# Provide API keys as arguments
python3 screen_dividend_stocks.py --use-finviz --fmp-api-key YOUR_FMP_KEY --finviz-api-key YOUR_FINVIZ_KEY
# Custom output location
python3 screen_dividend_stocks.py --use-finviz --output /path/to/results.json
# Get top 50 stocks
python3 screen_dividend_stocks.py --use-finviz --top 50
Environment Variables:
FMP_API_KEY - Financial Modeling Prep API key
FINVIZ_API_KEY - FINVIZ Elite API key (required for --use-finviz)
""",
)
parser.add_argument(
"--fmp-api-key", type=str, help="FMP API key (or set FMP_API_KEY environment variable)"
)
parser.add_argument(
"--finviz-api-key",
type=str,
help="FINVIZ Elite API key (or set FINVIZ_API_KEY environment variable)",
)
parser.add_argument(
"--use-finviz",
action="store_true",
help="Use FINVIZ Elite API for pre-screening (recommended to reduce FMP API calls)",
)
# Determine default output directory (project root logs/ folder)
script_dir = os.path.dirname(os.path.abspath(__file__))
# Navigate from .claude/skills/value-dividend-screener/scripts to project root
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(script_dir))))
logs_dir = os.path.join(project_root, "logs")
default_output = os.path.join(logs_dir, "dividend_screener_results.json")
parser.add_argument(
"--output",
type=str,
default=default_output,
help=f"Output JSON file path (default: {default_output})",
)
parser.add_argument(
"--top", type=int, default=20, help="Number of top stocks to return (default: 20)"
)
parser.add_argument(
"--max-candidates",
type=int,
default=300,
help=(
"FMP-only mode: max candidates (largest dividend payers by market cap) to "
"value-check via /stable/ratios for P/E and P/B (default: 300). Bounds API usage "
"since /stable cannot filter P/E or P/B server-side. Ignored with --use-finviz."
),
)
args = parser.parse_args()
# Get FMP API key
fmp_api_key = args.fmp_api_key or os.environ.get("FMP_API_KEY")
if not fmp_api_key:
print(
"ERROR: FMP API key required. Provide via --fmp-api-key or FMP_API_KEY environment variable",
file=sys.stderr,
)
sys.exit(1)
# FINVIZ pre-screening (optional)
finviz_symbols = None
if args.use_finviz:
finviz_api_key = args.finviz_api_key or os.environ.get("FINVIZ_API_KEY")
if not finviz_api_key:
print(
"ERROR: FINVIZ API key required when using --use-finviz. Provide via --finviz-api-key or FINVIZ_API_KEY environment variable",
file=sys.stderr,
)
sys.exit(1)
print(f"\n{'=' * 60}", file=sys.stderr)
print("VALUE DIVIDEND STOCK SCREENER (TWO-STAGE)", file=sys.stderr)
print(f"{'=' * 60}\n", file=sys.stderr)
finviz_client = FINVIZClient(finviz_api_key)
finviz_symbols = finviz_client.screen_stocks()
if not finviz_symbols:
print("ERROR: FINVIZ pre-screening failed or returned no results", file=sys.stderr)
sys.exit(1)
else:
print(f"\n{'=' * 60}", file=sys.stderr)
print("VALUE DIVIDEND STOCK SCREENER (FMP ONLY)", file=sys.stderr)
print(f"{'=' * 60}\n", file=sys.stderr)
# Run detailed screening
results = screen_value_dividend_stocks(
fmp_api_key,
top_n=args.top,
finviz_symbols=finviz_symbols,
max_candidates=args.max_candidates,
)
if not results:
print("\nNo stocks found matching all criteria.", file=sys.stderr)
sys.exit(1)
# Add metadata
output_data = {
"metadata": {
"generated_at": datetime.utcnow().isoformat() + "Z",
"criteria": {
"dividend_yield_min": 3.0,
"pe_ratio_max": 20,
"pb_ratio_max": 2,
"dividend_cagr_min": 4.0,
"dividend_stability": "low volatility, year-over-year growth",
"revenue_trend": "positive over 3 years",
"eps_trend": "positive over 3 years",
},
"scoring": {
"dividend_growth": "max 15 points (10%+ CAGR)",
"dividend_stability": "max 20 points (stable, growing)",
"revenue_growth": "max 10 points (10%+ CAGR)",
"eps_growth": "max 10 points (15%+ CAGR)",
"dividend_sustainable": "10 points",
"financial_health": "10 points",
"quality_score": "max 25 points",
},
"total_results": len(results),
},
"stocks": results,
}
# Write to file
os.makedirs(os.path.dirname(args.output), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
print(f"\n{'=' * 60}", file=sys.stderr)
print(f"✅ Screening complete! Found {len(results)} stocks.", file=sys.stderr)
print(f"📄 Results saved to: {args.output}", file=sys.stderr)
print(f"{'=' * 60}\n", file=sys.stderr)
if __name__ == "__main__":
main()
"""Make the skill's scripts/ importable and keep tests offline/fast."""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch):
"""Neutralize the FMP rate-limit sleep so tests run instantly."""
import screen_dividend_stocks as mod
monkeypatch.setattr(mod.time, "sleep", lambda *_a, **_k: None)
"""FMP /stable migration for value-dividend-screener.
The retired v3 endpoints 403 for keys issued after 2025-08-31. FMPClient._get
now routes the legacy v3 path-style endpoint strings to their /stable
query-style equivalents (with a v3 fallback) and normalizes the responses back
to the v3 shapes callers expect:
- historical-price-full/stock_dividend/{sym} -> dividends?symbol= (flat list
wrapped as {"historical": [...]})
- key-metrics: roe <- returnOnEquity
- cash-flow-statement: dividendsPaid <- netDividendsPaid
screen_stocks() also replaces the v3 server-side yield/P-E/P-B filter (which
company-screener cannot do) with client-side gates.
"""
from datetime import date
from unittest.mock import MagicMock
from screen_dividend_stocks import FMPClient, StockAnalyzer
def _quarterly(annual_by_year):
"""Build quarterly dividend records: {year: annual_amount} -> 4 records/year."""
recs = []
for year, annual in annual_by_year.items():
for month in ("02", "05", "08", "11"):
recs.append({"date": f"{year}-{month}-15", "dividend": round(annual / 4, 6)})
return recs
def _resp(status_code, payload):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = payload
return resp
def _client(router):
client = FMPClient("test_key")
session = MagicMock()
session.get.side_effect = router
client.session = session
return client, session
class TestGetRouting:
def test_statement_path_to_stable_query(self):
def router(url, params=None, timeout=None):
return _resp(200, [{"symbol": "AAPL", "revenue": 1, "eps": 2}])
client, session = _client(router)
client.get_income_statement("AAPL", limit=5)
call = session.get.call_args_list[0]
assert call[0][0].endswith("/stable/income-statement")
assert call[1]["params"]["symbol"] == "AAPL"
assert call[1]["params"]["limit"] == 5
def test_screener_to_company_screener(self):
client, session = _client(lambda *a, **k: _resp(200, [{"symbol": "X"}]))
client._get("stock-screener", {"marketCapMoreThan": 1})
assert session.get.call_args_list[0][0][0].endswith("/stable/company-screener")
def test_dividends_normalized_to_historical(self):
flat = [{"symbol": "AAPL", "date": "2026-05-11", "dividend": 0.27}]
client, _ = _client(lambda *a, **k: _resp(200, flat))
result = client.get_dividend_history("AAPL")
assert isinstance(result, dict)
assert result["historical"] == flat
def test_key_metrics_roe_alias(self):
client, _ = _client(lambda *a, **k: _resp(200, [{"returnOnEquity": 1.52}]))
metrics = client.get_key_metrics("AAPL", limit=1)
assert metrics[0]["roe"] == 1.52
def test_cashflow_dividends_paid_alias(self):
client, _ = _client(lambda *a, **k: _resp(200, [{"netDividendsPaid": -15_000_000}]))
cf = client.get_cash_flow("AAPL", limit=1)
assert cf[0]["dividendsPaid"] == -15_000_000
def test_historical_prices_flat_list_stable(self):
# /stable/historical-price-eod/full returns a flat list (most-recent-first).
bars = [{"date": "2026-05-19", "close": 100.0}, {"date": "2026-05-18", "close": 99.0}]
def router(url, params=None, timeout=None):
assert url.endswith("/stable/historical-price-eod/full")
assert "from" in (params or {}) and "to" in (params or {})
return _resp(200, bars)
client, _ = _client(router)
result = client.get_historical_prices("AAPL", days=30)
assert result == bars # flat list returned directly, close field intact
def test_v3_fallback_when_stable_fails(self):
def router(url, params=None, timeout=None):
if "/stable/" in url:
return _resp(403, {})
return _resp(200, [{"symbol": "AAPL", "sector": "Tech"}])
client, session = _client(router)
profile = client.get_company_profile("AAPL")
assert profile["sector"] == "Tech"
urls = [c[0][0] for c in session.get.call_args_list]
assert any(u.endswith("/api/v3/profile/AAPL") for u in urls)
class TestDividendGrowthTTM:
"""Dividend growth uses trailing-12m windows, not partial calendar years."""
def test_ttm_cagr_ignores_partial_and_future(self):
# ~10%/yr grower paying continuously through the as_of date. _quarterly
# emits Feb/May/Aug/Nov; for 2026 the Aug/Nov payments are future-dated
# (after as_of) and must be excluded, as must the explicit future record.
recs = _quarterly({2022: 1.0, 2023: 1.1, 2024: 1.21, 2025: 1.331, 2026: 1.4641})
recs.append({"date": "2026-08-15", "dividend": 99.0}) # future-dated -> ignored
cagr, consistent, latest = StockAnalyzer.analyze_dividend_growth(
{"historical": recs}, years_back=3, as_of=date(2026, 5, 20)
)
assert cagr is not None and 5 < cagr < 15 # ~10%, not the broken negative
assert consistent is True
assert latest < 5 # future 99.0 excluded from the trailing window
def test_partial_year_would_break_calendar_sum(self):
# Sanity: the same data summed by calendar year (old method) goes negative.
by_year = {"2023": 1.1, "2024": 1.21, "2025": 1.331, "2026": 1.4641 / 4}
years = sorted(by_year)[-4:]
vals = [by_year[y] for y in years]
old_cagr = StockAnalyzer.calculate_cagr(vals[0], vals[-1], 3)
assert old_cagr is not None and old_cagr < 0 # the bug the TTM fix avoids
class TestDividendStabilityExcludesPartialYear:
def test_flat_dividend_is_stable_despite_partial_current_year(self):
recs = _quarterly({y: 1.0 for y in range(2022, 2026)}) # flat $1/yr complete
recs.append({"date": "2026-02-15", "dividend": 0.25}) # partial current year
recs.append({"date": "2026-09-15", "dividend": 0.25}) # future-dated
result = StockAnalyzer.analyze_dividend_stability(
{"historical": recs}, as_of=date(2026, 5, 20)
)
assert "2026" not in result["annual_dividends"] # current year excluded
assert result["volatility_pct"] < 5 # flat complete years -> low volatility
assert result["is_stable"] is True
class TestScreenStocksClientSideGates:
def test_yield_pe_pb_gates_exclude_funds(self):
# HIYLD passes yield+P/E+P/B; LOWYLD fails yield; HIPE fails P/E;
# a high-yield ETF must be excluded before any ratios call.
universe = [
{"symbol": "HIYLD", "price": 100.0, "lastAnnualDividend": 5.0, "marketCap": 50e9},
{"symbol": "LOWYLD", "price": 100.0, "lastAnnualDividend": 1.0, "marketCap": 80e9},
{"symbol": "HIPE", "price": 100.0, "lastAnnualDividend": 4.0, "marketCap": 40e9},
{
"symbol": "BOND",
"price": 100.0,
"lastAnnualDividend": 6.0,
"marketCap": 90e9,
"isEtf": True,
}, # high-yield fund -> excluded
]
ratios = {
"HIYLD": [{"priceToEarningsRatio": 12.0, "priceToBookRatio": 1.5}],
"HIPE": [{"priceToEarningsRatio": 40.0, "priceToBookRatio": 1.0}], # P/E too high
}
def router(url, params=None, timeout=None):
if url.endswith("/company-screener"):
return _resp(200, universe)
if url.endswith("/ratios"):
sym = (params or {}).get("symbol")
assert sym != "BOND", "ETF should never reach the ratios call"
return _resp(200, ratios.get(sym, []))
raise AssertionError(f"unexpected {url}")
client, _ = _client(router)
survivors = client.screen_stocks(
dividend_yield_min=3.0, pe_max=20, pb_max=2, max_candidates=300
)
assert [s["symbol"] for s in survivors] == ["HIYLD"]
assert survivors[0]["pe"] == 12.0 # attached for the report
assert survivors[0]["priceToBook"] == 1.5
def test_prioritizes_by_market_cap(self):
# Two qualifying names; the cap of 1 must keep the larger-cap one.
universe = [
{"symbol": "SMALL", "price": 100.0, "lastAnnualDividend": 5.0, "marketCap": 5e9},
{"symbol": "BIG", "price": 100.0, "lastAnnualDividend": 4.0, "marketCap": 200e9},
]
def router(url, params=None, timeout=None):
if url.endswith("/company-screener"):
return _resp(200, universe)
return _resp(200, [{"priceToEarningsRatio": 10.0, "priceToBookRatio": 1.0}])
client, _ = _client(router)
survivors = client.screen_stocks(
dividend_yield_min=3.0, pe_max=20, pb_max=2, max_candidates=1
)
assert [s["symbol"] for s in survivors] == ["BIG"] # market-cap priority
def test_cap_limits_ratios_calls(self):
universe = [
{"symbol": f"S{i}", "price": 100.0, "lastAnnualDividend": 5.0} for i in range(10)
]
def router(url, params=None, timeout=None):
if url.endswith("/company-screener"):
return _resp(200, universe)
return _resp(200, [{"priceToEarningsRatio": 10.0, "priceToBookRatio": 1.0}])
client, session = _client(router)
client.screen_stocks(dividend_yield_min=3.0, pe_max=20, pb_max=2, max_candidates=3)
ratios_calls = [c for c in session.get.call_args_list if c[0][0].endswith("/ratios")]
assert len(ratios_calls) == 3 # capped, not 10
Related skills
How it compares
Pick value-dividend-screener when live FMP dividend fundamentals matter more than generic market research without API-backed screening.
FAQ
What does value-dividend-screener do?
Screen US stocks for high-quality dividend opportunities combining value characteristics (P/E ratio under 20, P/B ratio under 2), attractive yields (3% or higher), and.
When should I use value-dividend-screener?
User user requests dividend stock screening, income portfolio ideas, or quality value stocks with strong fundamentals.
Is value-dividend-screener safe to install?
Review the Security Audits panel on this page before installing in production.