references/canslim_methodology.md
# CANSLIM Methodology - William O'Neil's Growth Stock Selection System
## Overview
CANSLIM is a proven growth stock selection methodology developed by William O'Neil, founder of Investor's Business Daily (IBD). Based on comprehensive analysis of the greatest winning stocks from 1953 to the present, CANSLIM identifies 7 common characteristics that multi-bagger stocks exhibit before their major price advances.
**Historical Performance**: IBD's studies show that stocks exhibiting all 7 CANSLIM traits delivered average gains of 100-300% over 1-3 year periods, with many producing returns exceeding 1,000%.
**Investment Philosophy**: CANSLIM focuses on identifying emerging growth leaders at the beginning of their major price advances - not after they've already run. The methodology emphasizes buying quality growth stocks during brief consolidations or pullbacks in confirmed market uptrends.
---
## The 7 CANSLIM Components
### C - Current Quarterly Earnings (15% Weight)
**O'Neil's Rule**: "Look for companies whose current quarterly earnings per share are up at least 18-20% compared to the same quarter the prior year."
#### Why It Matters
Earnings are the primary fundamental driver of stock prices. Companies with accelerating quarterly earnings signal business momentum, competitive advantages, and management execution. O'Neil's research found that 3 out of 4 winning stocks showed EPS growth of at least 70% in the quarter before their major price advance began.
#### Quantitative Criteria
- **Minimum**: 18% year-over-year (YoY) quarterly EPS growth
- **Preferred**: 25%+ YoY growth
- **Exceptional**: 50%+ YoY growth with accelerating trend
#### Scoring Formula (0-100 Points)
```
100 points: EPS growth >= 50% AND revenue growth >= 25% (explosive acceleration)
80 points: EPS growth 30-49% AND revenue growth >= 15% (strong growth)
60 points: EPS growth 18-29% AND revenue growth >= 10% (meets minimum)
40 points: EPS growth 10-17% (below threshold)
0 points: EPS growth < 10% or negative
```
#### Revenue Growth Verification
**Critical Check**: EPS growth must be supported by revenue growth. If EPS grows significantly faster than revenue, investigate whether growth is driven by cost-cutting, share buybacks, or accounting adjustments rather than genuine business expansion.
**Red Flag**: EPS growth > 30% while revenue growth < 10% suggests unsustainable earnings quality.
#### Historical Examples
- **AAPL (2009 Q3)**: EPS +45% YoY (iPhone 3GS launch), stock gained 200% over next 2 years
- **NFLX (2013 Q1)**: EPS +278% YoY (streaming acceleration), stock up 400% in 18 months
- **TSLA (2020 Q3)**: EPS turned positive (first sustained profitability), stock up 700% in 12 months
- **NVDA (2023 Q2)**: EPS +429% YoY (AI chip demand), stock up 240% YTD
---
### A - Annual Earnings Growth (20% Weight)
**O'Neil's Rule**: "Annual earnings per share should be up 25% or more in each of the last three years."
#### Why It Matters
While current quarterly earnings (C) signal near-term momentum, annual earnings growth (A) validates sustained competitive advantages and business model strength. One-quarter wonders rarely sustain price gains. Multi-year consistency separates authentic growth companies from cyclical or temporary winners.
#### Quantitative Criteria
- **Minimum**: 25% annual EPS CAGR over 3 years
- **Preferred**: 30-40% annual CAGR
- **Exceptional**: 40%+ annual CAGR with no down years
#### 3-Year CAGR Calculation
```python
# Compound Annual Growth Rate formula
EPS_CAGR = ((EPS_current / EPS_3_years_ago) ^ (1/3)) - 1
# Example: MSFT 2017-2020
# EPS: $3.25 (2017) → $5.76 (2020)
# CAGR = (5.76 / 3.25) ^ (1/3) - 1 = 21.0%
```
#### Scoring Formula (0-100 Points)
```
90 points: EPS CAGR >= 40% AND stable (no down years) AND revenue CAGR >= 20%
70 points: EPS CAGR 30-39% AND stable
50 points: EPS CAGR 25-29% (meets CANSLIM minimum)
30 points: EPS CAGR 15-24% (below threshold)
0 points: EPS CAGR < 15% or erratic (down years present)
Bonus: +10 points if all 3 years show year-over-year growth (stability bonus)
Penalty: -20% of score if revenue CAGR < 50% of EPS CAGR (buyback-driven growth)
```
#### Growth Stability Assessment
**Stable Growth** (preferred): EPS increases every year over the 3-year period
- Example: $1.00 → $1.30 → $1.69 → $2.20 (consistent 30% growth)
**Erratic Growth** (penalty): One or more years show declining EPS
- Example: $1.00 → $1.50 → $1.20 → $1.80 (volatile, uncertain)
**O'Neil's Insight**: "It's better to see three years of 25% growth than one year of 80% followed by two years of 10%."
#### Revenue Growth Validation
**Healthy Growth**: EPS CAGR and Revenue CAGR both strong (within 10 percentage points)
- Example: EPS 30% CAGR + Revenue 25% CAGR = Sustainable
**Warning Sign**: EPS CAGR >> Revenue CAGR (gap > 15 percentage points)
- Likely driven by margin expansion, buybacks, or cost cuts (less sustainable)
- Example: EPS 35% CAGR + Revenue 10% CAGR = Investigate quality
#### Historical Examples
- **V (2015-2018)**: EPS CAGR 29%, Revenue CAGR 18% → 180% stock gain
- **MA (2014-2017)**: EPS CAGR 33%, Revenue CAGR 14% → 200% stock gain
- **MSFT (2017-2020)**: EPS CAGR 21%, Revenue CAGR 13% → 280% stock gain
- **NVDA (2020-2023)**: EPS CAGR 76%, Revenue CAGR 52% → 450% stock gain
---
### N - New Products, Management, or Highs (15% Weight)
**O'Neil's Rule**: "Stocks making new price highs have no overhead supply (resistance), signaling strong demand. New products, services, or management can catalyze major moves."
#### Why It Matters
Price action near 52-week highs indicates institutional accumulation and investor demand. New products create earnings catalysts. New management brings strategic changes. The "N" component combines technical momentum (new highs) with fundamental catalysts (newness).
**Key Insight**: 95% of CANSLIM winning stocks made new all-time highs before their major advance. Stocks far from highs face resistance and lack sponsorship.
#### Quantitative Criteria
**Price Position**:
- **Ideal**: Within 5% of 52-week high
- **Acceptable**: Within 15% of 52-week high
- **Caution**: 15-25% below 52-week high
- **Avoid**: >25% below 52-week high (lacks momentum)
**Breakout Pattern** (bonus):
- New 52-week high achieved on volume 40-50%+ above average
- Signals institutional buying (large orders driving price)
#### Scoring Formula (0-100 Points)
```python
# Distance from 52-week high
distance_pct = ((current_price / week_52_high) - 1) * 100
# Base score from price position
100 points: distance <= 5% from high AND breakout detected AND new product/catalyst
80 points: distance <= 10% from high AND breakout detected
60 points: distance <= 15% from high OR breakout detected
40 points: distance <= 25% from high
20 points: distance > 25% from high (insufficient momentum)
# Bonus for new product/catalyst signals (from news)
+10-20 points: Keywords detected - "FDA approval", "new product", "patent granted", "breakthrough"
```
#### New Product/Catalyst Detection (Supplementary)
**High-Impact Catalysts**:
- FDA drug approvals (pharmaceuticals)
- New platform/service launches (technology)
- Patent grants (defensive moat)
- Strategic acquisitions (expansion)
- New management from successful companies
**Data Source**: Keyword search in recent news headlines (FMP news API)
- "FDA approval" → +20 points
- "new product", "product launch" → +10 points
- "acquisition", "expansion" → +10 points
**Note**: Price action (new highs) is the primary signal (80% of N score). New product detection is supplementary (20% of N score).
#### Historical Examples
**New Highs + New Products**:
- **AAPL (2007)**: iPhone launch + new highs → 600% gain in 5 years
- **TSLA (2020)**: Model 3 production scale + new highs → 700% gain in 12 months
- **NVDA (2023)**: AI chip (H100) demand + new highs → 240% YTD gain
**New Highs After Consolidation**:
- **MSFT (2018)**: Azure cloud growth + breakout from 3-year consolidation → 350% gain in 3 years
- **META (2023)**: AI efficiency gains + breakout from 2022 bear market → 200% gain in 12 months
**Caution - Stocks Far from Highs**:
- Stocks 30-50% below highs rarely lead; they often become "dead money" for years
- Exception: Deep pullbacks in bear markets can reset; wait for new highs in next bull market
---
### M - Market Direction (5% Weight)
**O'Neil's Rule**: "You can be right about a stock but wrong about the market, and still lose money. Three out of four stocks follow the market's trend."
#### Why It Matters
CANSLIM doesn't work in bear markets. Even the best growth stocks decline 20-50% in sustained downtrends. O'Neil's research shows:
- **Bull markets**: 75% of stocks move with market direction
- **Bear markets**: Profitable stock picking nearly impossible
- **Market timing**: Staying in cash during corrections preserves capital for next bull phase
**Critical Insight**: "The most important decision is not which stock to buy, but whether to be invested at all."
#### Quantitative Criteria
**Primary Signal**: S&P 500 vs. 50-day Exponential Moving Average (EMA)
- **Uptrend**: S&P 500 closes above 50-day EMA for 3+ consecutive days
- **Choppy/Neutral**: S&P 500 oscillating around 50-day EMA (±2%)
- **Downtrend**: S&P 500 closes below 50-day EMA for 3+ consecutive days
**Secondary Signal**: VIX (Fear Gauge)
- **Low Fear**: VIX < 15 (complacent, supportive environment)
- **Normal**: VIX 15-20 (healthy market)
- **Elevated**: VIX 20-30 (caution, potential volatility)
- **Panic**: VIX > 30 (sell signal, go to cash)
#### Scoring Formula (0-100 Points)
```python
# Calculate distance from 50-day EMA
distance_from_ema = (sp500_price / sp500_ema_50) - 1
# Market trend scoring
100 points: sp500 > EMA by 2%+ AND VIX < 15 AND follow-through day detected
80 points: sp500 > EMA by 1-2% AND VIX < 20
60 points: sp500 > EMA by 0-1% (early uptrend)
40 points: sp500 within ±2% of EMA (choppy, neutral)
20 points: sp500 < EMA by 1-3% (early downtrend)
0 points: sp500 < EMA by 3%+ OR VIX > 30 (bear market - DO NOT BUY)
```
#### Follow-Through Day (FTD) - O'Neil's Bull Market Confirmation
**Definition**: A day when a major index (S&P 500, Nasdaq) rallies 1.25%+ on volume higher than the previous day, occurring on Day 4-10 of an attempted rally after a correction.
**Significance**:
- Signals institutional buying has resumed
- Confirms market bottoming process complete
- Green light to begin buying leading growth stocks
**Without FTD**: Rally attempts often fail; premature buying leads to losses
#### Market Direction Interpretation
**100 Points (Strong Uptrend)**:
- S&P 500 well above 50-day EMA
- VIX low (complacent)
- Follow-through day confirmed
- **Action**: Aggressively buy leading growth stocks (CANSLIM candidates)
**60 Points (Early Uptrend)**:
- S&P 500 just crossed above 50-day EMA
- Market still establishing trend
- **Action**: Start small positions (25-50% allocation)
**40 Points (Choppy/Neutral)**:
- S&P 500 oscillating around 50-day EMA
- Direction unclear
- **Action**: Reduce exposure to 25-50%, wait for confirmation
**0 Points (Downtrend/Bear Market)**:
- S&P 500 below 50-day EMA and declining
- VIX elevated or spiking
- **Action**: Sell all stocks, raise 80-100% cash, wait for FTD
#### Historical Examples
**Bull Market Phases (M Score 80-100)**:
- 2009-2011: Post-financial crisis recovery (AAPL, PCLN major winners)
- 2013-2015: QE-driven rally (NFLX, FB explosive gains)
- 2016-2018: Tax reform bull run (NVDA, AMD, FANG stocks)
- 2020-2021: Pandemic recovery (TSLA, NVDA, growth stocks)
- 2023-2024: AI boom (NVDA, META, MAG7)
**Bear Market Phases (M Score 0-20)**:
- 2008 (Financial Crisis): Even best stocks fell 40-60%
- 2011 (Debt Ceiling Crisis): 20% correction
- 2015-2016 (China Devaluation): Growth stocks down 20-30%
- 2018 (Fed Tightening): Nasdaq down 23%
- 2022 (Inflation/Fed): Nasdaq down 33%, growth stocks down 50-80%
**Key Takeaway**: In bear markets (M score < 20), CANSLIM candidates still decline despite strong fundamentals. Market direction trumps stock selection.
---
### S - Supply and Demand (15% Weight)
**O'Neil's Rule**: "Volume is the gas in the tank of a stock. Without gas, the car doesn't go anywhere. Look for stocks where volume expands on up days and contracts on down days."
#### Why It Matters
Volume patterns reveal institutional accumulation (buying) or distribution (selling). Individual investors trade in small lots (100-1,000 shares), but institutions move millions of shares. When institutions accumulate, volume surges on up days. When they distribute, volume spikes on down days. Volume precedes price - smart money moves before the crowd notices.
**Key Principle**: UP-DAY VOLUME > DOWN-DAY VOLUME = Accumulation (bullish)
#### Quantitative Criteria
**Accumulation/Distribution Analysis** (60-day period):
- Classify each day as up-day (close > previous close) or down-day (close < previous close)
- Calculate average volume for up-days vs. down-days
- **Accumulation Ratio** = Avg Up-Day Volume / Avg Down-Day Volume
**Thresholds**:
- **Strong Accumulation**: Ratio ≥ 2.0 (institutions aggressively buying)
- **Accumulation**: Ratio 1.5-2.0 (bullish volume pattern)
- **Neutral**: Ratio 1.0-1.5 (slightly positive)
- **Distribution**: Ratio 0.5-0.7 (institutions selling)
- **Strong Distribution**: Ratio < 0.5 (heavy selling pressure)
#### Scoring Formula (0-100 Points)
```python
# Calculate accumulation/distribution ratio
up_days_volume = [volume for day in last_60_days if close > prev_close]
down_days_volume = [volume for day in last_60_days if close < prev_close]
avg_up_volume = sum(up_days_volume) / len(up_days_volume)
avg_down_volume = sum(down_days_volume) / len(down_days_volume)
ratio = avg_up_volume / avg_down_volume
# Scoring
100 points: ratio >= 2.0 (Strong Accumulation)
80 points: ratio 1.5-2.0 (Accumulation)
60 points: ratio 1.0-1.5 (Neutral/Weak Accumulation)
40 points: ratio 0.7-1.0 (Neutral/Weak Distribution)
20 points: ratio 0.5-0.7 (Distribution)
0 points: ratio < 0.5 (Strong Distribution)
```
#### Historical Examples
- **NVDA (2023)**: Up/down ratio 2.3 before 500% run (massive institutional accumulation)
- **META (2023)**: Up/down ratio 1.8 during recovery from 2022 lows
- **TSLA (2019-2020)**: Ratio 2.1 during Model 3 production ramp
- **AAPL (2019)**: Ratio 1.7 before iPhone 11 cycle launched
**Red Flag**: Ratio < 0.7 signals distribution - institutions are selling into strength. Avoid or exit positions.
---
### I - Institutional Sponsorship (10% Weight)
**O'Neil's Rule**: "You need some of the big boys on your side. Look for stocks with increasing institutional sponsorship, but not too much. The sweet spot is 50-100 institutional holders with 30-60% ownership."
#### Why It Matters
Institutional investors (mutual funds, pension funds, hedge funds) have research teams, long time horizons, and large capital pools. Their sponsorship provides:
1. **Liquidity**: Enables smooth trading without excessive slippage
2. **Price Support**: Large holders defend positions during pullbacks
3. **Discovery**: Attracts additional institutional and retail capital
4. **Validation**: Smart money confirms the investment thesis
**Key Insight**: Too little institutional ownership (< 20%) means the stock is neglected. Too much (> 80%) means institutions have already bought - no buying power left to drive future gains.
#### Quantitative Criteria
**Holder Count**:
- **Sweet Spot**: 50-100 institutional holders
- **Good**: 30-50 holders (building interest)
- **Acceptable**: 100-150 holders (getting crowded)
- **Avoid**: < 30 holders (underowned) or > 150 holders (overcrowded)
**Ownership Percentage**:
- **Ideal Range**: 30-60% institutional ownership
- **Acceptable**: 20-30% or 60-80%
- **Caution**: < 20% (neglected) or > 80% (saturated)
**Quality Signal**: Superinvestor Presence (bonus)
- Holdings by legendary investors: Berkshire Hathaway, Baupost Group, Pershing Square, Greenlight Capital, Third Point, Appaloosa Management
#### Scoring Formula (0-100 Points)
```python
# Calculate institutional ownership %
total_shares_held = sum(holder.shares for holder in institutional_holders)
ownership_pct = (total_shares_held / shares_outstanding) * 100
# Scoring logic
if 50 <= num_holders <= 100 and 30 <= ownership_pct <= 60:
score = 100 # O'Neil's sweet spot
elif superinvestor_present and 30 <= num_holders <= 150:
score = 90 # Superinvestor quality signal
elif (30 <= num_holders < 50 and 20 <= ownership_pct <= 40) or \
(100 < num_holders <= 150 and 40 <= ownership_pct <= 70):
score = 80 # Good ranges
elif (20 <= num_holders < 30 and 20 <= ownership_pct <= 50) or \
(50 <= num_holders <= 150 and 20 <= ownership_pct <= 70):
score = 60 # Acceptable
elif ownership_pct < 20 or ownership_pct > 80:
score = 40 # Suboptimal ownership
elif ownership_pct < 10 or ownership_pct > 90:
score = 20 # Extreme ownership (avoid)
else:
score = 50 # Default
# Superinvestor bonus
if superinvestor_present and score < 100:
score = min(score + 10, 100)
```
#### Interpretation
**100 Points (Ideal)**:
- 50-100 holders, 30-60% ownership
- **Action**: Perfect institutional backing - proceed with confidence
**90 Points (Quality Signal)**:
- Superinvestor holding + good holder count
- **Action**: Quality investors provide margin of safety
**60 Points (Acceptable)**:
- Decent institutional interest, but not optimal
- **Action**: Proceed cautiously, monitor for changes
**40 Points (Suboptimal)**:
- Either underowned (< 20%) or overcrowded (> 80%)
- **Action**: Investigate why institutions are avoiding or have saturated
**20 Points (Avoid)**:
- Extreme ownership levels (< 10% or > 90%)
- **Action**: Pass - either neglected or no buying power left
#### Historical Examples
- **NVDA (2023 Q2)**: 74 holders, 44% ownership → 240% gain YTD
- **META (2023 Q1)**: 68 holders, 51% ownership → 194% recovery
- **TSLA (2020)**: Increased from 35 to 89 holders during 700% run
- **AAPL (2019)**: Berkshire Hathaway holding (superinvestor) → confidence signal
**Warning**: Stocks with > 150 holders and > 80% ownership often underperform - institutions have already bought, creating selling pressure during any weakness.
---
## Phase 2 Implementation Notes
This Phase 2 implementation includes **C, A, N, S, I, M** components, covering 80% of the full CANSLIM methodology weight. Phase 2 adds two critical components to Phase 1:
1. **S (Supply/Demand)**: Volume-based accumulation/distribution analysis - reveals institutional buying/selling
2. **I (Institutional Sponsorship)**: Holder count and ownership percentage - validates "smart money" backing
**Key Improvements Over Phase 1**:
- More accurate filtering: Volume patterns (S) eliminate stocks with distribution
- Quality validation: Institutional backing (I) confirms investment thesis
- Better ranking: Stocks with both strong fundamentals AND institutional support rise to top
- API efficient: Phase 2 requires ~203 calls for 40 stocks (81% of free tier)
### Component Weights (Phase 2 - Renormalized)
| Component | Original Weight | Phase 2 Weight | Rationale |
|-----------|-----------------|----------------|-----------|
| C - Current Earnings | 15% | **19%** | Core fundamental - quarterly momentum |
| A - Annual Growth | 20% | **25%** | Validates sustainability |
| N - Newness | 15% | **19%** | Momentum confirmation |
| S - Supply/Demand | 15% | **19%** | Institutional accumulation signal |
| I - Institutional | 10% | **13%** | Smart money validation |
| M - Market Direction | 5% | **6%** | Gating filter |
| **Subtotal (Phase 2)** | **80%** | **100%** | Renormalized to 100% (excluding L) |
**Future Phases**:
- **Phase 3**: Add L (Leadership/RS Rank) → 100% complete CANSLIM
- **Phase 4**: FINVIZ Elite integration for 10x speed improvement
### Interpretation for Phase 2 Scores
Phase 2 implements 6 of 7 components (80% weight), providing substantially more accurate screening than Phase 1:
- **90-100 points** (Phase 2): Exceptional+ (Rare multi-bagger setup with institutional backing)
- **80-89 points** (Phase 2): Exceptional (Outstanding fundamentals + accumulation)
- **70-79 points** (Phase 2): Strong (High-quality CANSLIM stock)
- **60-69 points** (Phase 2): Above Average (Watchlist - monitor for improvement)
- **<60 points** (Phase 2): Below CANSLIM standards
**Minimum Thresholds (Phase 2)**:
All 6 components must meet baseline criteria:
- C ≥ 60 (18%+ quarterly EPS growth)
- A ≥ 50 (25%+ annual CAGR)
- N ≥ 40 (within 15% of 52-week high)
- S ≥ 40 (accumulation pattern, ratio ≥ 1.0)
- I ≥ 40 (30+ holders OR 20%+ ownership)
- M ≥ 40 (market in uptrend)
**Key Difference from Phase 1**: Phase 2 scores reflect institutional validation (S, I). A stock with great fundamentals (C, A) but distribution pattern (S < 40) will score poorly, protecting against false positives.
---
## Investment Philosophy and Best Practices
### When CANSLIM Works Best
1. **Confirmed Bull Markets**: S&P 500 above 50-day and 200-day moving averages
2. **Growth-Favoring Environment**: Low interest rates, economic expansion, innovation cycles
3. **Sector Rotation into Growth**: Technology, healthcare, consumer discretionary leading
4. **Positive Market Breadth**: Majority of stocks in uptrends (>50% above 200-day MA)
### When to Avoid CANSLIM
1. **Bear Markets**: Major indices below 200-day MA, VIX > 30
2. **Value-Favoring Markets**: High inflation, rising rates favor defensive stocks
3. **Sector Rotation into Defensives**: Utilities, consumer staples, REITs leading
4. **Negative Breadth**: Majority of stocks in downtrends, breadth deteriorating
### Position Management Rules
**Entry**:
- Buy stocks scoring 80+ (Phase 1) or 140+ (Full CANSLIM)
- Enter on pullback to 10-week moving average (best risk/reward)
- Position size: 10-20% of portfolio per stock, 5-10 total positions
**Stops**:
- Initial stop loss: 7-8% below entry (O'Neil's strict rule)
- Move to breakeven once stock up 15%
- Trail stop 10-15% below peak as stock advances
**Profit Taking**:
- Sell 20-25% of position when up 20-25% (lock in partial gains)
- Sell another 20-25% when up 50%
- Let winners run if still in Stage 2 uptrend (hold final 50%)
**Sell Signals**:
1. Stop loss hit (7-8% loss) - no exceptions
2. Market enters correction (M score drops to 0-20)
3. Fundamental deterioration (C or A component score drops below 40)
4. Climax top (parabolic move on extreme volume, then reversal)
5. Distribution (heavy volume on down days, institutional selling)
---
## Common Mistakes and How to Avoid Them
### Mistake 1: Ignoring Market Direction (M Component)
**Error**: Buying CANSLIM stocks in bear markets because "they have great fundamentals"
**Reality**: 75% of stocks follow market direction. Even perfect CANSLIM candidates decline 20-50% in sustained downtrends.
**Solution**:
- Check M component FIRST before analyzing individual stocks
- If M score < 40, raise cash to 80-100% and wait
- "Being right about a stock but wrong about the market still loses money"
### Mistake 2: Chasing Stocks Far from Highs
**Error**: Buying stocks 30-50% below 52-week highs because "they're on sale"
**Reality**: Stocks trading far from highs lack institutional sponsorship and face overhead resistance. They rarely lead the next advance.
**Solution**:
- Focus on N component: stocks within 15% of 52-week high
- Wait for proper base breakout (7-8 weeks minimum consolidation)
- "Leaders of the last bull market rarely lead the next one"
### Mistake 3: Overweighting Quarterly Earnings, Ignoring Annual Growth
**Error**: Buying stocks with explosive Q1 EPS growth (C) but erratic 3-year history (A)
**Reality**: One-quarter wonders often revert. Sustainable winners show consistent multi-year growth.
**Solution**:
- Require BOTH C >= 60 AND A >= 50 (Phase 1 scoring)
- View A component as validation of C component
- "Consistency beats volatility in long-term wealth building"
### Mistake 4: Not Cutting Losses Quickly
**Error**: Holding losing positions hoping they "come back"
**Reality**: O'Neil's studies show that cutting losses at 7-8% prevents small losses from becoming large disasters.
**Solution**:
- Set stop loss at entry (7-8% below buy price)
- No exceptions: stop hit = sell immediately
- "Your first loss is your smallest loss - take it"
---
## Conclusion
CANSLIM is a time-tested, quantitative methodology for identifying emerging growth leaders before major price advances. The system combines fundamental analysis (C, A) with technical confirmation (N, M) to filter for stocks with institutional sponsorship and momentum.
**Phase 1 MVP (C, A, N, M)** provides immediate value by screening for stocks with:
- Accelerating quarterly earnings (C)
- Consistent multi-year growth (A)
- Price momentum near new highs (N)
- Confirmation of market uptrend (M)
**Future Phases** will add supply/demand analysis (S), institutional sponsorship tracking (I), and relative strength leadership ranking (L) to create a complete implementation of O'Neil's system.
**Remember**: CANSLIM is a growth stock methodology best applied during confirmed bull markets. In bear markets, the best action is to raise cash and wait for the next follow-through day signaling institutional buying has resumed.
---
## References and Further Reading
- **"How to Make Money in Stocks"** by William J. O'Neil (4th Edition, 2009)
- **"The Successful Investor"** by William J. O'Neil (2004)
- **IBD (Investor's Business Daily)** - Daily CANSLIM stock screens and market analysis
- **MarketSmith** - IBD's institutional-grade stock analysis platform with official RS Ranks
- **Historical Studies**: IBD's analysis of 600+ winning stocks (1953-2008) validating CANSLIM
references/fmp_api_endpoints.md
# FMP API Endpoints - CANSLIM Screener Phase 3 (Full CANSLIM)
## Overview
This document specifies the Financial Modeling Prep (FMP) API endpoints required for the CANSLIM screener Phase 3 implementation (all 7 components: C, A, N, S, L, I, M).
**Base URL**: `https://financialmodelingprep.com/api/v3`
**Authentication**: All requests require `apikey` parameter
**Rate Limiting**:
- Free tier: 250 requests/day
- Recommended delay: 0.3 seconds between requests (200 requests/minute max)
---
## C Component - Current Quarterly Earnings
### Endpoint: Income Statement (Quarterly)
**URL**: `/income-statement/{symbol}?period=quarter&limit=8`
**Method**: GET
**Parameters**:
- `symbol`: Stock ticker (e.g., "AAPL")
- `period`: "quarter" (quarterly data)
- `limit`: 8 (fetch last 8 quarters = 2 years)
- `apikey`: Your FMP API key
**Example Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/income-statement/AAPL?period=quarter&limit=8&apikey=YOUR_KEY"
```
**Response Fields Used**:
```json
[
{
"date": "2023-09-30", # Quarter end date
"symbol": "AAPL",
"reportedCurrency": "USD",
"fillingDate": "2023-11-02",
"eps": 1.46, # Diluted EPS ← KEY
"epsdiلuted": 1.46, # Alternative field
"revenue": 89498000000, # Total revenue ← KEY
"grossProfit": 41104000000,
"operatingIncome": 26982000000,
"netIncome": 22956000000
},
# ... 7 more quarters
]
```
**Usage**:
- Compare `eps` from most recent quarter to quarter 4 positions back (YoY comparison)
- Compare `revenue` same way
- Calculate YoY growth percentage
**API Calls**: 1 per stock
---
## A Component - Annual EPS Growth
### Endpoint: Income Statement (Annual)
**URL**: `/income-statement/{symbol}?period=annual&limit=5`
**Method**: GET
**Parameters**:
- `symbol`: Stock ticker
- `period`: "annual" (annual data)
- `limit`: 5 (fetch last 5 years for 4-year CAGR calculation)
- `apikey`: Your FMP API key
**Example Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/income-statement/AAPL?period=annual&limit=5&apikey=YOUR_KEY"
```
**Response Fields Used**:
```json
[
{
"date": "2023-09-30", # Fiscal year end
"symbol": "AAPL",
"eps": 6.13, # Annual diluted EPS ← KEY
"revenue": 383285000000, # Annual revenue ← KEY
"netIncome": 96995000000
},
# ... 4 more years
]
```
**Usage**:
- Use 4 most recent years to calculate 3-year CAGR
- CAGR = ((EPS_current / EPS_3years_ago) ^ (1/3)) - 1
- Check for stability (no down years)
- Validate with revenue CAGR
**API Calls**: 1 per stock
---
## N Component - Newness / New Highs
### Endpoint 1: Historical Prices (Daily)
**URL**: `/historical-price-full/{symbol}?timeseries=365`
**Method**: GET
**Parameters**:
- `symbol`: Stock ticker
- `timeseries`: 365 (fetch last 365 days)
- `apikey`: Your FMP API key
**Example Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/historical-price-full/AAPL?timeseries=365&apikey=YOUR_KEY"
```
**Response Fields Used**:
```json
{
"symbol": "AAPL",
"historical": [
{
"date": "2024-01-10",
"open": 185.16,
"high": 186.40, # Daily high ← KEY
"low": 184.00, # Daily low ← KEY
"close": 185.92, # Close price ← KEY
"volume": 50123456 # Daily volume ← KEY
},
# ... 364 more days
]
}
```
**Usage**:
- Calculate 52-week high: `max(historical[0:252].high)`
- Calculate 52-week low: `min(historical[0:252].low)`
- Current price: `historical[0].close`
- Distance from high: `(current / 52wk_high - 1) * 100`
- Detect breakout: Check if recent high >= 52wk_high with elevated volume
**API Calls**: 1 per stock
### Endpoint 2: Quote (Real-Time Price)
**URL**: `/quote/{symbol}`
**Method**: GET
**Parameters**:
- `symbol`: Stock ticker (can be comma-separated for batch)
- `apikey`: Your FMP API key
**Example Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/quote/AAPL?apikey=YOUR_KEY"
```
**Response Fields Used**:
```json
[
{
"symbol": "AAPL",
"name": "Apple Inc.",
"price": 185.92, # Current price ← KEY
"changesPercentage": 1.23,
"change": 2.25,
"dayLow": 184.00,
"dayHigh": 186.40,
"yearHigh": 198.23, # 52-week high ← KEY
"yearLow": 164.08, # 52-week low ← KEY
"marketCap": 2913000000000,
"volume": 50123456,
"avgVolume": 48000000, # Average volume ← KEY
"exchange": "NASDAQ",
"sector": "Technology"
}
]
```
**Usage**:
- Alternative to historical prices for 52-week high/low
- Faster (1 call vs historical prices) but less granular
- Use for quick screening; historical prices for detailed analysis
**API Calls**: 1 per stock (or batch multiple)
### Endpoint 3: Stock News (Optional - New Product Detection)
**URL**: `/stock_news?tickers={symbol}&limit=50`
**Method**: GET
**Parameters**:
- `tickers`: Stock ticker (comma-separated for multiple)
- `limit`: 50 (recent news articles)
- `apikey`: Your FMP API key
**Example Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/stock_news?tickers=AAPL&limit=50&apikey=YOUR_KEY"
```
**Response Fields Used**:
```json
[
{
"symbol": "AAPL",
"publishedDate": "2024-01-10T14:30:00.000Z",
"title": "Apple Launches Revolutionary AI Chip", # ← KEY (keyword search)
"image": "https://...",
"site": "Reuters",
"text": "Apple Inc announced today...",
"url": "https://..."
},
# ... 49 more articles
]
```
**Usage**:
- Search `title` field for keywords:
- High impact: "FDA approval", "patent granted", "breakthrough"
- Moderate: "new product", "product launch", "acquisition"
- Bonus points for N component if catalyst detected
- **Optional**: Can skip to reduce API calls (N component primarily uses price action)
**API Calls**: 1 per stock (optional, can be skipped to save quota)
---
## M Component - Market Direction
### Endpoint 1: Quote (Major Indices)
**URL**: `/quote/^GSPC,^IXIC,^DJI`
**Method**: GET
**Parameters**:
- Symbol: `^GSPC` (S&P 500), `^IXIC` (Nasdaq), `^DJI` (Dow Jones)
- Can batch multiple indices in single call
- `apikey`: Your FMP API key
**Example Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/quote/%5EGSPC,%5EIXIC,%5EDJI?apikey=YOUR_KEY"
```
**Response Fields Used**:
```json
[
{
"symbol": "^GSPC",
"name": "S&P 500",
"price": 4783.45, # Current level ← KEY
"changesPercentage": 0.85,
"change": 40.25,
"dayLow": 4750.20,
"dayHigh": 4790.10,
"yearHigh": 4818.62,
"yearLow": 4103.78,
"marketCap": null,
"volume": null,
"avgVolume": null
},
# IXIC, DJI...
]
```
**Usage**:
- Get current S&P 500 price for trend analysis
- Compare to 50-day EMA (from separate call or calculated locally)
**API Calls**: 1 (batch call for all indices)
### Endpoint 2: Historical Prices (S&P 500 for EMA Calculation)
**URL**: `/historical-price-full/^GSPC?timeseries=60`
**Method**: GET
**Parameters**:
- Symbol: `^GSPC` (S&P 500)
- `timeseries`: 60 (fetch 60 days for 50-day EMA calculation)
- `apikey`: Your FMP API key
**Example Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/historical-price-full/%5EGSPC?timeseries=60&apikey=YOUR_KEY"
```
**Response Fields Used**:
```json
{
"symbol": "^GSPC",
"historical": [
{
"date": "2024-01-10",
"close": 4783.45 # ← KEY (for EMA calculation)
},
# ... 59 more days
]
}
```
**Usage**:
- Calculate 50-day EMA from closing prices
- EMA formula: `EMA_today = (Price_today * k) + (EMA_yesterday * (1 - k))` where `k = 2/(50+1)`
- Alternative: Use simple moving average (SMA) for simplicity
**API Calls**: 1 (reused for all stocks)
### Endpoint 3: VIX (Fear Gauge)
**URL**: `/quote/^VIX`
**Method**: GET
**Parameters**:
- Symbol: `^VIX` (CBOE Volatility Index)
- `apikey`: Your FMP API key
**Example Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/quote/%5EVIX?apikey=YOUR_KEY"
```
**Response Fields Used**:
```json
[
{
"symbol": "^VIX",
"name": "CBOE Volatility Index",
"price": 13.24, # Current VIX level ← KEY
"changesPercentage": -2.15,
"change": -0.29
}
]
```
**Usage**:
- VIX < 15: Low fear (bullish environment)
- VIX 15-20: Normal (healthy market)
- VIX 20-30: Elevated (caution)
- VIX > 30: Panic (bear market signal)
**API Calls**: 1 (reused for all stocks)
---
## L Component - Leadership / Relative Strength (Phase 3)
### Endpoint: Historical Prices (52-Week)
**URL**: `/v3/historical-price-full/{symbol}?timeseries=365`
**Purpose**: Calculate 52-week stock performance vs S&P 500 benchmark for RS Rank estimation
**Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/historical-price-full/NVDA?timeseries=365&apikey=YOUR_KEY"
```
**Response Structure**:
```json
{
"symbol": "NVDA",
"historical": [
{
"date": "2025-01-10",
"close": 148.50,
"open": 146.20,
"high": 149.80,
"low": 145.90,
"volume": 250000000
}
// ... 364 more days
]
}
```
**Usage**:
```python
# Stock 52-week performance
current_price = historical[0]['close']
price_52w_ago = historical[-1]['close'] # ~252 trading days
stock_perf = ((current_price / price_52w_ago) - 1) * 100
# Compare vs S&P 500 (^GSPC) for RS calculation
relative_perf = stock_perf - sp500_perf
```
**S&P 500 Benchmark Data**: S&P 500 52-week historical prices are fetched once using `^GSPC` (same endpoint, shared for both M component EMA and L component RS calculation). Both quote and historical use `^GSPC` to ensure price scale consistency.
**API Calls**: 1 per stock (52-week data) + 1 shared S&P 500 call
---
## S Component - Supply and Demand
### Endpoint: Historical Prices (Already Fetched for N Component)
**URL**: `/v3/historical-price-full/{symbol}?timeseries=90`
**Purpose**: Volume-based accumulation/distribution analysis
**Data Reuse**: S component uses the same historical_prices data already fetched for N component (52-week high calculation). **No additional API calls required.**
**Algorithm**:
```python
# Classify last 60 days into up-days and down-days
for day in last_60_days:
if close > previous_close:
up_days.append(volume)
elif close < previous_close:
down_days.append(volume)
# Calculate accumulation/distribution ratio
avg_up_volume = sum(up_days) / len(up_days)
avg_down_volume = sum(down_days) / len(down_days)
ratio = avg_up_volume / avg_down_volume
```
**Scoring**:
- Ratio ≥ 2.0: 100 points (Strong Accumulation)
- Ratio 1.5-2.0: 80 points (Accumulation)
- Ratio 1.0-1.5: 60 points (Neutral/Weak Accumulation)
- Ratio 0.7-1.0: 40 points (Neutral/Weak Distribution)
- Ratio 0.5-0.7: 20 points (Distribution)
- Ratio < 0.5: 0 points (Strong Distribution)
**API Calls**: 0 (data already fetched)
---
## I Component - Institutional Sponsorship (Phase 2)
### Endpoint: Institutional Holders
**URL**: `/v3/institutional-holder/{symbol}`
**Purpose**: Analyze institutional holder count and ownership percentage
**Authentication**: Requires FMP API key (available on free tier)
**Request**:
```bash
curl "https://financialmodelingprep.com/api/v3/institutional-holder/AAPL?apikey=YOUR_KEY"
```
**Response Structure**:
```json
[
{
"holder": "Vanguard Group Inc",
"shares": 1295611697,
"dateReported": "2024-09-30",
"change": 12500000,
"changePercent": 0.0097
},
{
"holder": "Blackrock Inc.",
"shares": 1042156037,
"dateReported": "2024-09-30",
"change": -5234567,
"changePercent": -0.0050
},
{
"holder": "Berkshire Hathaway Inc",
"shares": 915560382,
"dateReported": "2024-09-30",
"change": 0,
"changePercent": 0.0000
}
// ... hundreds more holders ...
]
```
**Key Fields**:
- `holder`: Institution name (string)
- `shares`: Number of shares held (int)
- `dateReported`: 13F filing date (string, YYYY-MM-DD)
- `change`: Change in shares from previous quarter (int)
- `changePercent`: Percentage change (float)
**Typical Response Size**: 100-7,000 holders per stock (AAPL has ~7,111 holders)
**Free Tier Availability**: ✅ Available (tested with AAPL on 2026-01-12)
**Usage**:
```python
# Calculate total institutional ownership
total_shares_held = sum(holder['shares'] for holder in institutional_holders)
ownership_pct = (total_shares_held / shares_outstanding) * 100
# Count unique holders
num_holders = len(institutional_holders)
# Detect superinvestors
SUPERINVESTORS = [
"BERKSHIRE HATHAWAY",
"BAUPOST GROUP",
"PERSHING SQUARE",
# ...
]
superinvestor_present = any(
superinvestor in holder['holder'].upper()
for holder in institutional_holders
for superinvestor in SUPERINVESTORS
)
```
**Scoring** (O'Neil's Criteria):
- 50-100 holders + 30-60% ownership: 100 points (Sweet spot)
- Superinvestor present + good holder count: 90 points
- 30-50 holders + 20-40% ownership: 80 points
- Acceptable ranges: 60 points
- Suboptimal (< 20% or > 80% ownership): 40 points
- Extreme (< 10% or > 90% ownership): 20 points
**API Calls**: 1 per stock
**Data Freshness**: Updated quarterly (13F filings due 45 days after quarter-end)
**Quality Notes**:
- Large-cap stocks (AAPL, MSFT): 5,000-10,000 holders
- Mid-cap stocks: 500-2,000 holders
- Small-cap stocks: 50-500 holders
- Micro-cap stocks: < 50 holders (may lack institutional interest)
---
## API Call Summary (Per Stock)
### Phase 3 (Full CANSLIM: C, A, N, S, L, I, M)
**Per-Stock Calls**:
1. Profile (for company info): 1 call
2. Quote (for current price): 1 call
3. Income Statement (Quarterly): 1 call
4. Income Statement (Annual): 1 call
5. Historical Prices (90 days): 1 call (reused for N and S)
6. Historical Prices (365 days): 1 call (for L component RS calculation)
7. Institutional Holders: 1 call
**Per-Stock Total**: 7 calls (profile, quote, income×2, historical_90d, historical_365d, institutional)
**Market Data Calls** (One-Time per Session):
1. S&P 500 Quote (`^GSPC`): 1 call (shared)
2. S&P 500 Historical (`^GSPC`, 365 days): 1 call (shared, used for both M component EMA and L component RS benchmark)
3. VIX Quote (`^VIX`): 1 call (shared)
**Market Data Total**: 3 calls
**Important**: Both the quote and historical data use `^GSPC` (S&P 500 index) to ensure price scale consistency for M component EMA calculation. Using SPY (ETF, ~500) for historical while ^GSPC (~5000) for quote would cause a ~10× scale mismatch.
**Total for 40 Stocks**:
- Per-stock: 40 stocks × 7 calls = 280 calls
- Market data: 3 calls
- **Grand Total: ~283 calls (exceeds 250 free tier limit)** ⚠️
**Key Efficiency**:
- S component: 0 extra calls (reuses historical_prices from N component's 90-day fetch)
- L component: +1 call per stock (365-day historical prices, separate cache key from 90-day)
- S&P 500 historical data: shared between M (EMA) and L (RS benchmark)
**Free Tier Workaround**: Use `--max-candidates 35` (35 × 7 + 3 = 248 calls, within 250 limit). For full 40-stock screening, upgrade to FMP Starter tier ($29.99/mo, 750 calls/day).
**Note on `mktCap` field**: FMP profile API returns `mktCap` (not `marketCap`). The screener handles both field names for compatibility.
---
## Rate Limiting Strategy
### Implementation
```python
import time
def rate_limited_get(url, params):
"""Enforce 0.3s delay between requests (200 requests/minute max)"""
response = requests.get(url, params=params)
time.sleep(0.3) # 300ms delay
return response
```
### Handling 429 Errors (Rate Limit Exceeded)
```python
def handle_rate_limit(response):
"""Retry once with 60-second wait if rate limit hit"""
if response.status_code == 429:
print("WARNING: Rate limit exceeded. Waiting 60 seconds...")
time.sleep(60)
return True # Signal to retry
return False # No retry needed
```
### Free Tier Management
**Daily Quota**: 250 requests/day
**Strategies to Stay Within Limits**:
1. **Batch calls where possible**: Use comma-separated symbols in quote endpoint
2. **Cache market data**: Fetch S&P 500/VIX once, reuse for all stocks
3. **Skip optional calls**: News endpoint can be omitted to save 40 calls
4. **Limit universe**: Analyze top 35 stocks for free tier, or 40 with Starter tier
5. **Progressive filtering**: Apply cheap filters first (market cap, sector) before expensive API calls
---
## Error Handling
### Common Errors
**401 Unauthorized**:
- Cause: Invalid or missing API key
- Solution: Verify `apikey` parameter, check environment variable
**404 Not Found**:
- Cause: Invalid symbol or endpoint
- Solution: Verify ticker symbol exists, check endpoint URL
**429 Too Many Requests**:
- Cause: Exceeded daily/minute rate limit
- Solution: Wait 60 seconds (minute limit) or 24 hours (daily limit)
**500 Internal Server Error**:
- Cause: FMP server issue
- Solution: Retry after 5 seconds, skip stock if persistent
**Empty Response `[]`**:
- Cause: Symbol exists but no data available (e.g., recent IPO, delisted stock)
- Solution: Skip stock, log warning
### Retry Logic
```python
MAX_RETRIES = 1
retry_count = 0
while retry_count <= MAX_RETRIES:
response = make_request()
if response.status_code == 200:
return response.json()
elif response.status_code == 429:
time.sleep(60)
retry_count += 1
else:
print(f"ERROR: {response.status_code}")
return None
print("ERROR: Max retries exceeded")
return None
```
---
## Data Quality Considerations
### Freshness
- **Quarterly/Annual Data**: Updated within 1-2 days of earnings release
- **Price Data**: Real-time (15-minute delay on free tier)
- **News Data**: Updated continuously
### Completeness
- **Large-cap stocks**: Complete historical data (10+ years)
- **Mid-cap stocks**: Mostly complete (5+ years typical)
- **Small-cap/Recent IPOs**: May have gaps (<2 years data)
### Validation
Always check for:
- `null` or `0` values in critical fields (EPS, revenue)
- Negative EPS when calculating growth rates (use absolute value in denominator)
- Missing quarters (delisted stocks, special situations)
---
## Example API Call Sequence
For analyzing NVDA:
```bash
# 1. Quarterly income statement (C component)
curl "https://financialmodelingprep.com/api/v3/income-statement/NVDA?period=quarter&limit=8&apikey=YOUR_KEY"
# 2. Annual income statement (A component)
curl "https://financialmodelingprep.com/api/v3/income-statement/NVDA?period=annual&limit=5&apikey=YOUR_KEY"
# 3. Historical prices (N component)
curl "https://financialmodelingprep.com/api/v3/quote/NVDA?apikey=YOUR_KEY"
# 4. S&P 500 for market direction (M component - once per session)
curl "https://financialmodelingprep.com/api/v3/quote/%5EGSPC&apikey=YOUR_KEY"
curl "https://financialmodelingprep.com/api/v3/historical-price-full/%5EGSPC?timeseries=60&apikey=YOUR_KEY"
curl "https://financialmodelingprep.com/api/v3/quote/%5EVIX&apikey=YOUR_KEY"
```
**Total**: 7 calls per stock (profile, quote, income×2, historical_90d, historical_365d, institutional) + 3 market calls (reused for all stocks)
---
## Cost Analysis
### Free Tier (250 requests/day)
- **40 stocks × 7 calls = 280 calls**
- **Market data: 3 calls**
- **Total: ~283 calls (exceeds 250 quota)** ⚠️
- **Workaround**: Use `--max-candidates 35` (35 × 7 + 3 = 248 calls)
### Paid Tiers
- **Starter ($29.99/month)**: 750 requests/day → ~106 stocks/run ((750 - 3) / 7)
- **Professional ($79.99/month)**: 2000 requests/day → ~285 stocks/run ((2000 - 3) / 7)
**Recommendation for Phase 3**: Free tier supports up to 35 stocks (`--max-candidates 35`). For the default 40-stock universe, upgrade to Starter tier ($29.99/mo).
---
This API reference provides complete documentation for implementing Phase 3 (Full CANSLIM) screening. Free tier users should use `--max-candidates 35` to stay within the 250 calls/day limit.
references/interpretation_guide.md
# CANSLIM Score Interpretation Guide - Phase 3 (Full CANSLIM)
## Overview
This guide helps users interpret CANSLIM screening results and translate composite scores into actionable investment decisions. Phase 3 implements all 7 components (C, A, N, S, L, I, M) with original O'Neil weights, presented on a 0-100 scale.
---
## Composite Score Interpretation
### Score Bands and Meanings
| Score Range | Rating | Meaning | Action | Position Size |
|-------------|--------|---------|--------|---------------|
| **90-100** | **Exceptional+** | Rare multi-bagger setup - all 4 components near-perfect | Immediate buy | 15-20% of portfolio |
| **80-89** | **Exceptional** | Outstanding fundamentals + strong momentum | Strong buy | 10-15% of portfolio |
| **70-79** | **Strong** | Solid across all components, minor weaknesses | Buy | 8-12% of portfolio |
| **60-69** | **Above Average** | Meets thresholds, one component weak | Buy on pullback | 5-8% of portfolio |
| **50-59** | **Average** | Marginal CANSLIM candidate | Watchlist only | Consider 3-5% if conviction high |
| **40-49** | **Below Average** | Fails one or more key thresholds | Monitor, do not buy | 0% |
| **<40** | **Weak** | Does not meet CANSLIM criteria | Avoid | 0% |
---
## Component-Level Analysis
### How to Read Individual Component Scores
Each component contributes to the composite score with different weights:
- **C (Current Earnings)**: 15% weight - Quarterly EPS acceleration
- **A (Annual Growth)**: 20% weight - Multi-year consistency
- **N (Newness)**: 15% weight - Momentum and price action
- **S (Supply/Demand)**: 15% weight - Volume accumulation/distribution
- **L (Leadership/RS Rank)**: 20% weight - Relative strength vs market (highest weight, tied with A!)
- **I (Institutional)**: 10% weight - Smart money confirmation
- **M (Market Direction)**: 5% weight - Macro environment filter
**Minimum Thresholds** (all must be met for "buy" rating):
- C >= 60 (18%+ quarterly EPS growth)
- A >= 50 (25%+ annual EPS CAGR)
- N >= 40 (within 25% of 52-week high)
- S >= 40 (accumulation pattern, ratio >= 1.0)
- L >= 50 (RS Rank 60+, outperforming market)
- I >= 40 (30+ holders or 20%+ ownership)
- M >= 40 (market not in downtrend)
### Component Score Interpretation
| Score | C (Earnings) | A (Growth) | N (Newness) | M (Market) |
|-------|-------------|-----------|------------|-----------|
| **100** | EPS +50%+, explosive | CAGR 40%+, stable | At new high + catalyst | Strong bull, VIX <15 |
| **80** | EPS +30-49%, strong | CAGR 30-39% | Within 10% of high | Bull market, VIX <20 |
| **60** | EPS +18-29%, meets min | CAGR 25-29% | Within 15% of high | Early uptrend |
| **40** | EPS +10-17%, below | CAGR 15-24% | Within 25% of high | Choppy/neutral |
| **20** | EPS <10%, weak | CAGR <15%, erratic | >25% from high | Downtrend forming |
| **0** | Negative growth | No growth/volatile | Far from highs | Bear market - DO NOT BUY |
---
## Action Recommendations by Score
### 90-100 Points (Exceptional+)
**Characteristics**:
- All 7 components aligned (C, A, N, S, L, I, M all 80+)
- Explosive earnings growth (C: 90-100)
- Multi-year growth validation (A: 90-100)
- At or near new highs with catalyst (N: 90-100)
- Strong volume accumulation (S: 80+)
- Relative strength leader (L: 90+)
- Strong institutional backing (I: 80+)
- Strong bull market environment (M: 90-100)
**Historical Examples**:
- NVDA (2023 Q2): Score ~97 (AI chip explosion)
- AAPL (2009 Q3): Score ~95 (iPhone 3GS launch)
- TSLA (2020 Q3): Score ~92 (Model 3 profitability)
**Investment Action**:
- **Immediate buy** - Do not wait for pullback (may never come)
- **Position size**: 15-20% of portfolio (high conviction)
- **Entry timing**: Buy now or on minor 2-3% dip
- **Stop loss**: 7-8% below entry (O'Neil's rule)
- **Profit target**: Let winners run; these can become 200-500% gainers
**Risk Management**:
- Even exceptional setups can fail - honor stop loss
- If market deteriorates (M drops to 0-20), sell regardless of stock quality
- Take partial profits (20-25% of position) at 20-25% gain
---
### 80-89 Points (Exceptional)
**Characteristics**:
- Most components strong, one may be slightly lower
- Strong earnings and growth (C + A both 70+)
- Good momentum (N 70+)
- Favorable market (M 60+)
**Typical Weakest Component**:
- Often A component (recovering from prior downturn, 3-year CAGR improving but not yet 40%+)
- Sometimes N component (8-12% from high, not quite breakout)
**Investment Action**:
- **Strong buy**
- **Position size**: 10-15% of portfolio (standard sizing)
- **Entry timing**: Ideal entry on pullback to 10-week moving average; can buy now if urgent
- **Stop loss**: 7-8% below entry
- **Profit target**: Sell 20-25% at 20-25% gain, hold remainder for 50-100%+ move
**Example**:
- Stock scoring 85: C=90, A=78, N=88, M=80
- Weakest = A (78): Growth is strong but not yet exceptional
- Action: Buy, expect 50-150% upside over 12-18 months if fundamentals hold
---
### 70-79 Points (Strong)
**Characteristics**:
- All components meet CANSLIM minimums
- One component may be outstanding, others solid
- No major red flags
**Typical Profile**:
- C: 70-80 (strong earnings, not explosive)
- A: 60-75 (meets 25% CAGR, could be more consistent)
- N: 65-80 (10-15% from high, good but not perfect momentum)
- M: 60-80 (bull market confirmed, not extended)
**Investment Action**:
- **Buy** (standard recommendation)
- **Position size**: 8-12% of portfolio
- **Entry timing**: Wait for pullback to 10-week MA or minor consolidation
- **Stop loss**: 7-8% below entry
- **Profit target**: 30-80% upside typical over 6-12 months
**Risk Consideration**:
- Monitor weakest component for deterioration
- If weakest drops below 50, consider reducing position
- These are quality candidates but not "home run" setups
---
### 60-69 Points (Above Average)
**Characteristics**:
- Meets CANSLIM minimums but lacks conviction
- Typically one component weak (40-50 range)
- Remaining components solid (60-70 range)
**Common Weak Points**:
- **N weak** (40-50): Stock 20-25% from 52-week high → Lacks momentum
- **A weak** (50-60): 3-year CAGR only 25-28% → Marginal growth rate
- **C weak** (50-60): Quarterly EPS growth 18-22% → Just meets minimum
**Investment Action**:
- **Buy on pullback** or **Watchlist**
- **Position size**: 5-8% of portfolio (conservative)
- **Entry timing**: Wait for confirmation (improvement in weak component, or pullback to support)
- **Stop loss**: 7-8% below entry
- **Profit target**: 20-40% upside typical
**Monitoring Strategy**:
- Set alerts for earnings reports (watch if C/A component improves)
- Check weekly if price approaches 52-week high (N component improving)
- If composite score rises to 70+, increase position
---
### 50-59 Points (Average)
**Characteristics**:
- Barely meets CANSLIM criteria
- Multiple components in 50-60 range (marginal)
- May have one strong component (70-80) but others drag down composite
**Investment Action**:
- **Watchlist only** - Do not buy yet
- **Position size**: 0% (not ready)
- **Monitoring**: Track for improvement in weak components
- **Potential catalyst**: Upcoming earnings report could boost C score
**When to Reconsider**:
- If next quarter shows earnings acceleration (C rises to 70+)
- If stock breaks to new 52-week high (N rises to 70+)
- If market strengthens significantly (M rises to 80+)
- Goal: Wait for composite to reach 65-70+ before buying
**Common Scenario**:
- Stock recovering from pullback
- Fundamentals improving but not yet exceptional
- Price consolidating, building base for next move
- **Patient approach**: Wait 1-2 quarters for confirmation
---
### 40-49 Points (Below Average)
**Characteristics**:
- Fails one or more key CANSLIM thresholds
- Typically C or A component below 50 (earnings/growth insufficient)
- Or N component very weak (stock far from highs, no momentum)
**Investment Action**:
- **Do not buy**
- **Position size**: 0%
- **Action**: Remove from watchlist unless fundamental catalyst expected
**Why Avoid**:
- Stocks scoring <50 rarely lead market advances
- Even if they rally, gains are typically modest (10-20%)
- Better opportunities exist in 70+ scorers
- "You don't get paid to be right about mediocre stocks"
**Exception**:
- If M (Market) component is weak (0-20) but C, A, N are all 70+
- This indicates bear market is suppressing good stocks
- Add to watchlist, wait for M to recover (next bull market)
---
### <40 Points (Weak)
**Characteristics**:
- Multiple components fail CANSLIM thresholds
- Earnings decelerating (C < 40)
- Or inconsistent growth history (A < 40)
- Or price action weak (N < 40)
**Investment Action**:
- **Avoid entirely**
- These stocks are not CANSLIM candidates
- Focus efforts on higher-scoring opportunities
---
## Weakest Component Analysis
Every stock report identifies the **weakest component** (lowest individual score). This guides risk assessment:
### If Weakest = C (Current Earnings)
**Risk**: Earnings deceleration or miss
**Monitor**: Upcoming quarterly earnings reports
**Warning signs**:
- Revenue growth slowing
- Margin compression
- Guidance lowered
**Action**:
- If C drops to <40 post-earnings → Sell immediately
- Set tighter stop loss (5-6% vs. standard 7-8%)
---
### If Weakest = A (Annual Growth)
**Risk**: Inconsistent multi-year history, or recovering from prior downturn
**Monitor**: Multi-quarter trends (is growth accelerating consistently?)
**Warning signs**:
- Next quarter shows deceleration (C component weakens)
- Revenue growth lags EPS growth significantly
**Action**:
- Acceptable if A >= 50 and trending up
- If A drops below 50 or C also weakens → Sell
- This is often a "prove it" situation - stock must deliver for 2-3 more quarters
---
### If Weakest = N (Newness)
**Risk**: Lacks price momentum, overhead resistance
**Monitor**: Weekly chart for breakout to new highs
**Warning signs**:
- Stock fails to participate in sector rallies
- Continues drifting further from 52-week high
- Volume declining (waning interest)
**Action**:
- Wait for N to improve (breakout confirmation) before buying
- If N drops to <30 → Remove from watchlist
- Stocks far from highs rarely lead; don't fight the tape
---
### If Weakest = M (Market Direction)
**Risk**: Macro headwinds, bear market
**Monitor**: S&P 500 vs. 50-day EMA, VIX level
**Warning signs**:
- S&P breaks below 50-day and 200-day MAs
- VIX spikes above 25-30
- Breadth deteriorates (more declining than advancing stocks)
**Action**:
- If M = 0-20 → Raise cash immediately to 80-100%
- Do not buy any stocks, even with perfect C, A, N
- Wait for follow-through day (FTD) signaling market bottom before re-entering
**O'Neil's Wisdom**: "The best offense in a bear market is cash. You can't lose money sitting on the sidelines."
---
## Market Direction Override
**Critical Rule**: If M component score = 0-20 (bear market), **do not buy any stocks** regardless of composite score.
### Why This Matters
Historical data shows:
- **Bull markets**: 75% of stocks move with market direction
- **Bear markets**: Even best growth stocks decline 20-50%
- **Poor timing**: Buying great stocks in bear markets = losses
### Example Scenario
**Stock XYZ**:
- C Score: 100 (exceptional earnings)
- A Score: 95 (exceptional growth)
- N Score: 98 (new highs)
- M Score: 0 (bear market)
- **Composite**: 85.3 (Exceptional by fundamentals)
**What happens**:
1. Initial purchase at $100
2. Bear market persists (M remains 0-20)
3. Stock declines to $70-80 despite strong fundamentals (-20-30%)
4. Stop loss triggered at $92-93 (-7-8% loss)
**Lesson**: Even 85-point stocks fail in bear markets. Wait for M > 40 (market recovery) before buying.
---
## Portfolio Construction Guidelines
### Position Sizing by Score
| Composite Score | Max Position Size | Rationale |
|----------------|------------------|-----------|
| 90-100 | 15-20% | High conviction, rare setups |
| 80-89 | 10-15% | Standard sizing for strong stocks |
| 70-79 | 8-12% | Solid candidates, slight caution |
| 60-69 | 5-8% | Smaller positions, higher uncertainty |
| <60 | 0% | Do not buy |
### Diversification Rules
**Total Portfolio**:
- 5-10 positions maximum (concentrated portfolio, O'Neil style)
- 10-20% cash reserve (for new opportunities)
- Rebalance when any position exceeds 25% due to gains
**Sector Limits**:
- Maximum 2 stocks per sector (avoid concentration risk)
- Exception: If sector is clearly leading (e.g., Technology in AI boom), can have 3
**Score Distribution**:
- 60-70% of portfolio: Stocks scoring 80+ (high conviction)
- 30-40% of portfolio: Stocks scoring 70-79 (solid candidates)
- 0% of portfolio: Stocks scoring <70 (only if very high conviction and specific catalyst)
---
## Entry and Exit Rules
### Entry Timing
**Best Entry Points** (O'Neil's research):
1. **Breakout from base** (7-8 week consolidation minimum)
- Stock makes new high on volume 40-50%+ above average
- Buy within 5% of breakout price ("buy point")
2. **Pullback to 10-week moving average**
- Stock in uptrend pulls back 8-12%
- Finds support at 10-week MA
- Buy when bounces off MA with volume
3. **Early-stage base** (Stages 1-2 of Weinstein cycle)
- Stock just emerging from consolidation
- Beginning Stage 2 uptrend
- Low-risk entry before major advance
### Stop Loss Discipline
**Initial Stop**: 7-8% below entry price (non-negotiable)
**Why 7-8%**:
- O'Neil's 50-year research: Cutting losses at 7-8% prevents small losses from becoming disasters
- Average successful trade: +20-25% gain
- Risk/reward: Lose 7%, make 20% = 3:1 favorable
**Stop Loss Adjustments**:
- Once stock up 15%: Move stop to breakeven (protect capital)
- Once stock up 25%: Trail stop 10-15% below peak
- In strong uptrend: Use 10-week MA as trailing stop
### Profit Taking
**Partial Profit Strategy**:
1. **First 20-25% gain**: Sell 20-25% of position (lock in profit)
2. **50% gain**: Sell another 20-25%
3. **Remainder**: Hold for potential multi-bagger (100-500%)
**Full Sell Signals**:
1. **Stop loss hit**: 7-8% loss → Sell immediately
2. **Climax top**: Parabolic move on extreme volume, then reversal → Sell 50-100%
3. **Distribution**: Heavy selling volume (stock down on high volume 4-5 days in week) → Sell
4. **Fundamental deterioration**: C or A component drops below 40 → Sell
5. **Market enters correction**: M score drops to 0-20 → Sell all stocks
---
## Common Interpretation Mistakes
### Mistake 1: Ignoring M Component
**Error**: "This stock has a 95 composite score, I must buy it!"
**Reality**: If M = 0, composite score is misleading. Stock will likely decline in bear market.
**Correct Approach**:
- Always check M component FIRST
- If M < 40, question whether to buy
- If M = 0, do NOT buy regardless of C, A, N scores
---
### Mistake 2: Chasing Low-Scoring Stocks
**Error**: "This 55-point stock is cheap, it could bounce!"
**Reality**: Stocks scoring <60 rarely lead market advances. Even if they rally, gains are modest.
**Correct Approach**:
- Focus on 70+ scorers
- "It's not about being right, it's about making money"
- Better to buy fewer high-quality stocks than many mediocre ones
---
### Mistake 3: Overweighting N Component
**Error**: "This stock is 40% below its high, so it's a bargain!"
**Reality**: Stocks far from highs lack institutional sponsorship and face overhead resistance.
**Correct Approach**:
- N >= 60 (within 15% of high) is minimum
- N >= 80 (within 10% of high) is ideal
- "Leaders make new highs; laggards don't"
---
### Mistake 4: Ignoring Weakest Component
**Error**: "Composite score is 75, that's strong enough to buy"
**Reality**: If weakest component is C = 40 (earnings decelerating), stock is at risk of sharp decline on next earnings miss.
**Correct Approach**:
- Identify weakest component
- Understand the specific risk it represents
- Monitor that component closely
- Set appropriate stop loss based on risk
---
### Mistake 5: Not Cutting Losses
**Error**: "I'll hold this losing position; it will come back eventually"
**Reality**: Small losses turn into large disasters. O'Neil's data shows 90% of losses come from not selling losers early.
**Correct Approach**:
- Set stop loss at entry (7-8% below)
- Execute stop loss without hesitation
- "Your first loss is your smallest loss"
- Free up capital for better opportunities
---
## Summary Checklist
Before buying any stock, verify:
- [ ] **Composite score >= 70** (Strong or higher)
- [ ] **M component >= 40** (Market not in downtrend)
- [ ] **C component >= 60** (Meets earnings minimum)
- [ ] **A component >= 50** (Meets growth minimum)
- [ ] **N component >= 40** (Not too far from highs)
- [ ] **S component >= 40** (Accumulation pattern present)
- [ ] **L component >= 50** (Outperforming market, RS leader)
- [ ] **I component >= 40** (Institutional backing present)
- [ ] **Weakest component identified** and understood
- [ ] **Stop loss set at 7-8% below entry**
- [ ] **Position size appropriate** (based on score tier)
- [ ] **Sector exposure acceptable** (max 2-3 stocks per sector)
- [ ] **Cash reserve maintained** (10-20% of portfolio)
If all boxes checked → **Execute purchase**
If any box unchecked → **Do not buy** (or wait for condition to be met)
---
This interpretation guide provides a systematic framework for translating CANSLIM scores into disciplined investment decisions. By following these guidelines, you implement O'Neil's research-backed methodology for identifying growth stock leaders while managing risk effectively.
references/scoring_system.md
# CANSLIM Scoring System - Phase 3 (Full CANSLIM)
## Overview
This document specifies the composite scoring system for the CANSLIM screener. Phase 3 implements all **7 of 7** components (C, A, N, S, L, I, M), representing 100% of the full CANSLIM methodology weight using O'Neil's original component weights.
---
## Component Weights
### Phase 3 Weights (Full CANSLIM - 7 Components)
| Component | Weight | Rationale |
|-----------|--------|-----------|
| **C** - Current Earnings | **15%** | Most predictive single factor (O'Neil's #1) |
| **A** - Annual Growth | **20%** | Validates sustainability of earnings growth |
| **N** - Newness | **15%** | Momentum confirmation critical |
| **S** - Supply/Demand | **15%** | Volume accumulation/distribution analysis |
| **L** - Leadership/RS Rank | **20%** | Largest weight (tied with A) - identifies sector leaders |
| **I** - Institutional Sponsorship | **10%** | Smart money confirmation |
| **M** - Market Direction | **5%** | Gating filter - affects all stocks |
| **Total** | **100%** | Original O'Neil weights |
### Legacy Phase Weights (Reference Only)
**Phase 1 MVP** (4 components - C, A, N, M): Renormalized to C 27%, A 36%, N 27%, M 10%
**Phase 2** (6 components - C, A, N, S, I, M): Renormalized to C 19%, A 25%, N 19%, S 19%, I 13%, M 6%
---
## Component Scoring Formulas (0-100 Scale)
Each component is scored on a 0-100 point scale based on O'Neil's quantitative thresholds.
### C - Current Quarterly Earnings (0-100 Points)
**Input Data Required:**
- Latest quarterly EPS (most recent quarter)
- Year-ago quarterly EPS (same quarter, prior year)
- Latest quarterly revenue
- Year-ago quarterly revenue
**Calculation:**
```python
eps_growth_pct = ((latest_qtr_eps - year_ago_qtr_eps) / abs(year_ago_qtr_eps)) * 100
revenue_growth_pct = ((latest_qtr_revenue - year_ago_qtr_revenue) / year_ago_qtr_revenue) * 100
```
**Scoring Logic:**
```python
if eps_growth_pct >= 50 and revenue_growth_pct >= 25:
c_score = 100 # Explosive growth
elif eps_growth_pct >= 30 and revenue_growth_pct >= 15:
c_score = 80 # Strong growth
elif eps_growth_pct >= 18 and revenue_growth_pct >= 10:
c_score = 60 # Meets CANSLIM minimum
elif eps_growth_pct >= 10:
c_score = 40 # Below threshold
else:
c_score = 0 # Weak or negative growth
```
**Interpretation:**
- **100 points**: Exceptional - Top-tier earnings acceleration
- **80 points**: Strong - Well above CANSLIM threshold
- **60 points**: Acceptable - Meets minimum 18% threshold
- **40 points**: Weak - Below CANSLIM standards
- **0 points**: Fails - Insufficient growth
**Quality Checks:**
- If revenue growth < 50% of EPS growth → Investigate earnings quality (potential buyback-driven)
- If revenue is negative while EPS is positive → Red flag (cost-cutting, not growth)
---
### A - Annual EPS Growth (0-100 Points)
**Input Data Required:**
- Annual EPS for current year and previous 3 years (4 years total)
- Annual revenue for same 4 years (validation)
**Calculation:**
```python
# 3-year CAGR (Compound Annual Growth Rate)
eps_cagr_3yr = (((current_year_eps / eps_3_years_ago) ** (1/3)) - 1) * 100
revenue_cagr_3yr = (((current_year_revenue / revenue_3_years_ago) ** (1/3)) - 1) * 100
# Growth stability check
eps_values = [year1_eps, year2_eps, year3_eps, year4_eps] # chronological order
stable = all(eps_values[i] >= eps_values[i-1] for i in range(1, 4)) # No down years
```
**Scoring Logic:**
```python
# Base score from EPS CAGR
if eps_cagr_3yr >= 40:
base_score = 90
elif eps_cagr_3yr >= 30:
base_score = 70
elif eps_cagr_3yr >= 25:
base_score = 50 # Meets CANSLIM minimum
elif eps_cagr_3yr >= 15:
base_score = 30
else:
base_score = 0
# Revenue growth validation penalty
if revenue_cagr_3yr < (eps_cagr_3yr * 0.5):
base_score = int(base_score * 0.8) # 20% penalty for weak revenue growth
# Stability bonus
if stable: # No down years
base_score += 10
a_score = min(base_score, 100) # Cap at 100
```
**Interpretation:**
- **90-100 points**: Exceptional - Sustainable high growth with stability
- **70-89 points**: Strong - Well above 25% threshold
- **50-69 points**: Acceptable - Meets CANSLIM minimum
- **30-49 points**: Weak - Below threshold
- **0-29 points**: Fails - Insufficient or erratic growth
**Quality Checks:**
- Stability bonus (+10) rewards consistency (no down years)
- Revenue validation prevents buyback-driven EPS growth from scoring high
---
### N - Newness / New Highs (0-100 Points)
**Input Data Required:**
- Current stock price
- 52-week high price
- 52-week low price
- Recent daily volume data (30 days)
- Average volume (30-day average)
- Recent news headlines (optional, for new product detection)
**Calculation:**
```python
# Distance from 52-week high
distance_from_high_pct = ((current_price / week_52_high) - 1) * 100
# Breakout detection (new high on volume)
breakout_detected = (
current_price >= week_52_high * 0.995 and # Within 0.5% of high
recent_volume > avg_volume * 1.4 # Volume 40%+ above average
)
# New product signal detection (keyword search in news)
new_product_signals = search_news_keywords([
"FDA approval", "patent granted", "breakthrough", "game-changer",
"new product", "product launch", "expansion", "acquisition"
])
```
**Scoring Logic:**
```python
# Base score from price position
if distance_from_high_pct >= -5 and breakout_detected and new_product_signals:
base_score = 100 # Perfect setup
elif distance_from_high_pct >= -10 and breakout_detected:
base_score = 80 # Strong momentum
elif distance_from_high_pct >= -15 or breakout_detected:
base_score = 60 # Acceptable
elif distance_from_high_pct >= -25:
base_score = 40 # Weak momentum
else:
base_score = 20 # Too far from highs
# Bonus for new product/catalyst signals (optional data)
if new_product_signals:
if "FDA approval" in signals or "breakthrough" in signals:
base_score += 20 # High-impact catalyst
elif "new product" in signals or "acquisition" in signals:
base_score += 10 # Moderate catalyst
n_score = min(base_score, 100) # Cap at 100
```
**Interpretation:**
- **90-100 points**: Exceptional - At new highs with catalysts
- **70-89 points**: Strong - Near highs with volume confirmation
- **50-69 points**: Acceptable - Within 15% of highs
- **30-49 points**: Weak - Lacks momentum
- **0-29 points**: Fails - Too far from highs, no sponsorship
**Note**: Price position is primary signal (80% of score). New product detection is supplementary (20% bonus).
---
### M - Market Direction (0-100 Points)
**Input Data Required:**
- S&P 500 current price
- S&P 500 50-day Exponential Moving Average (EMA)
- VIX current level
- Follow-through day detection (optional advanced feature)
**Calculation:**
```python
# Distance from 50-day EMA
distance_from_ema_pct = ((sp500_price / sp500_ema_50) - 1) * 100
# Trend determination
if distance_from_ema_pct >= 2.0:
trend = "strong_uptrend"
elif distance_from_ema_pct >= 0:
trend = "uptrend"
elif distance_from_ema_pct >= -2.0:
trend = "choppy"
elif distance_from_ema_pct >= -5.0:
trend = "downtrend"
else:
trend = "bear_market"
```
**Scoring Logic:**
```python
# Base score from trend
if trend == "strong_uptrend" and vix < 15:
base_score = 100 # Ideal conditions
elif trend == "strong_uptrend" or (trend == "uptrend" and vix < 20):
base_score = 80 # Favorable
elif trend == "uptrend":
base_score = 60 # Acceptable
elif trend == "choppy":
base_score = 40 # Neutral/caution
elif trend == "downtrend":
base_score = 20 # Weak market
else: # bear_market or vix > 30
base_score = 0 # Avoid stocks entirely
# VIX adjustment (fear gauge)
if vix < 15:
base_score += 10 # Low fear, bullish
elif vix > 30:
base_score = 0 # Panic, override trend
# Follow-through day bonus (optional advanced feature)
if follow_through_day_detected:
base_score += 10 # Confirmed institutional buying
m_score = min(max(base_score, 0), 100) # Cap between 0-100
```
**Interpretation:**
- **90-100 points**: Strong bull market - Aggressive buying recommended
- **70-89 points**: Bull market - Standard position sizing
- **50-69 points**: Early uptrend - Small initial positions
- **30-49 points**: Choppy/neutral - Reduce exposure, be selective
- **10-29 points**: Downtrend - Defensive posture, minimal positions
- **0 points**: Bear market - Raise 80-100% cash, do not buy
**Critical Rule**: If M score = 0, **do not buy any stocks** regardless of other component scores. Market direction trumps stock selection.
---
### L - Leadership / Relative Strength (0-100 Points)
**Input Data Required:**
- Stock 52-week historical prices
- S&P 500 52-week historical prices (benchmark)
**Calculation:**
```python
# 52-week stock performance
stock_perf = ((current_price / price_52w_ago) - 1) * 100
# 52-week S&P 500 performance
sp500_perf = ((sp500_current / sp500_52w_ago) - 1) * 100
# Relative performance
relative_perf = stock_perf - sp500_perf
# RS Rank estimate (1-99 scale)
rs_rank = calculate_rs_rank(relative_perf)
```
**Scoring Logic:**
```python
if rs_rank >= 90:
base_score = 100 # Top decile leader
elif rs_rank >= 80:
base_score = 80 # Strong leader
elif rs_rank >= 70:
base_score = 60 # Above average
elif rs_rank >= 60:
base_score = 40 # Average
else:
base_score = 20 # Laggard
```
**Interpretation:**
- **90-100 points**: Top RS leader - stock significantly outperforming market
- **70-89 points**: Strong relative strength - outperforming market
- **50-69 points**: Average relative strength
- **30-49 points**: Below average - underperforming market
- **0-29 points**: Laggard - significantly underperforming, avoid per CANSLIM
**O'Neil's Rule**: "Buy stocks with an RS rating of 80 or higher. Avoid laggards below 70."
---
## Composite Score Calculation
### Formula (Phase 3 - Full CANSLIM)
```python
composite_score = (
c_score * 0.15 + # Current Earnings: 15% weight
a_score * 0.20 + # Annual Growth: 20% weight
n_score * 0.15 + # Newness: 15% weight
s_score * 0.15 + # Supply/Demand: 15% weight
l_score * 0.20 + # Leadership/RS Rank: 20% weight
i_score * 0.10 + # Institutional: 10% weight
m_score * 0.05 # Market Direction: 5% weight
)
# Result: 0-100 composite score
```
### Interpretation Bands (Phase 3)
| Score Range | Rating | Percentile | Meaning | Action |
|-------------|--------|------------|---------|--------|
| **90-100** | **Exceptional+** | Top 1-2% | Rare multi-bagger setup with full institutional backing | Immediate buy, aggressive sizing (15-20% position) |
| **80-89** | **Exceptional** | Top 5-10% | Outstanding fundamentals + accumulation | Strong buy, standard sizing (10-15% position) |
| **70-79** | **Strong** | Top 15-20% | High-quality CANSLIM stock | Buy on pullback, standard sizing (10-15%) |
| **60-69** | **Above Average** | Top 30% | Solid candidate, minor weaknesses | Watchlist, smaller sizing (5-10%) on pullback |
| **50-59** | **Average** | Top 50% | Meets minimums, lacks conviction | Watchlist, wait for improvement |
| **40-49** | **Below Average** | Bottom 50% | One or more components weak | Monitor only, do not buy |
| **<40** | **Weak** | Bottom 30% | Fails CANSLIM criteria | Avoid |
### Weakest Component Identification
For each stock, identify the component with the **lowest individual score** to guide further analysis:
```python
components = {
'C': c_score,
'A': a_score,
'N': n_score,
'S': s_score,
'L': l_score,
'I': i_score,
'M': m_score
}
weakest_component = min(components, key=components.get)
weakest_score = components[weakest_component]
```
**Use Case**: Helps user understand risks
- Weakest = C → Earnings deceleration risk
- Weakest = A → Lack of sustained growth history
- Weakest = N → Lacks momentum, far from highs
- Weakest = S → Distribution pattern, institutions selling
- Weakest = L → Lagging market, not a sector leader
- Weakest = I → Underowned or overcrowded, investigate further
- Weakest = M → Poor market timing, consider waiting
### Formula (Phase 2 - 6 Components)
```python
composite_score = (
c_score * 0.19 + # Current Earnings: 19% weight
a_score * 0.25 + # Annual Growth: 25% weight
n_score * 0.19 + # Newness: 19% weight
s_score * 0.19 + # Supply/Demand: 19% weight (NEW)
i_score * 0.13 + # Institutional: 13% weight (NEW)
m_score * 0.06 # Market Direction: 6% weight
)
# Result: 0-100 composite score (Phase 2)
```
### Interpretation Bands (Phase 2)
| Score Range | Rating | Percentile | Meaning | Action |
|-------------|--------|------------|---------|--------|
| **90-100** | **Exceptional+** | Top 1-2% | Rare multi-bagger setup with full institutional backing | Immediate buy, aggressive sizing (15-20% position) |
| **80-89** | **Exceptional** | Top 5-10% | Outstanding fundamentals + accumulation | Strong buy, standard sizing (10-15% position) |
| **70-79** | **Strong** | Top 15-20% | High-quality CANSLIM stock | Buy on pullback, standard sizing (10-15%) |
| **60-69** | **Above Average** | Top 30% | Solid candidate, minor weaknesses | Watchlist, smaller sizing (5-10%) on pullback |
| **<60** | **Below Standard** | Bottom 70% | Fails one or more thresholds | Monitor only, do not buy |
**Key Improvement**: Phase 2 scores include institutional validation (S, I components), making them more predictive than Phase 1.
### Minimum Thresholds (Phase 2)
All 6 components must meet baseline criteria to qualify as a CANSLIM candidate:
```python
thresholds = {
"C": 60, # 18%+ quarterly EPS growth
"A": 50, # 25%+ annual CAGR
"N": 40, # Within 15% of 52-week high
"S": 40, # Accumulation pattern (ratio ≥ 1.0)
"I": 40, # 30+ holders OR 20%+ ownership
"M": 40 # Market in uptrend
}
# Stock passes if ALL components >= thresholds
passes_threshold = all(score >= thresholds[comp] for comp, score in scores.items())
```
**Failure Interpretation**:
- Fails C threshold → Earnings deceleration, avoid
- Fails A threshold → Lacks sustained growth, not a growth stock
- Fails N threshold → Too far from highs, wait for strength
- Fails S threshold → Distribution pattern, institutions selling
- Fails I threshold → Neglected by institutions, lacks backing
- Fails M threshold → Bear market, wait for market recovery
### Weakest Component Identification (Phase 2)
```python
components = {
'C': c_score,
'A': a_score,
'N': n_score,
'S': s_score, # NEW
'I': i_score, # NEW
'M': m_score
}
weakest_component = min(components, key=components.get)
weakest_score = components[weakest_component]
```
**Additional Interpretations**:
- Weakest = S → Distribution pattern, institutions selling - caution
- Weakest = I → Underowned or overcrowded, investigate further
---
## Example Calculations
### Example 1: NVDA (2023 Q2) - Exceptional Setup
**Component Scores:**
- **C Score**: 100 points (EPS +429% YoY, Revenue +101% YoY)
- **A Score**: 95 points (3yr CAGR 89%, stable, revenue strong)
- **N Score**: 98 points (New all-time high, AI catalyst, breakout volume)
- **M Score**: 100 points (S&P 500 in strong uptrend, VIX <15)
**Composite Calculation:**
```python
composite = (100 * 0.27) + (95 * 0.36) + (98 * 0.27) + (100 * 0.10)
= 27.0 + 34.2 + 26.46 + 10.0
= 97.66 points
```
**Rating**: Exceptional (97.66/100)
**Interpretation**: Textbook CANSLIM setup - all components aligned, rare multi-bagger candidate
**Weakest Component**: A (95) - even this is exceptional
**Action**: Strong buy, aggressive position sizing (15-20% of portfolio)
---
### Example 2: META (2023 Q3) - Strong Setup
**Component Scores:**
- **C Score**: 85 points (EPS +164% YoY, Revenue +23% YoY)
- **A Score**: 78 points (3yr CAGR 28%, recovery from 2022 trough, stable recent)
- **N Score**: 88 points (5% from 52-week high, breakout pattern)
- **M Score**: 80 points (S&P 500 above EMA, VIX 18)
**Composite Calculation:**
```python
composite = (85 * 0.27) + (78 * 0.36) + (88 * 0.27) + (80 * 0.10)
= 22.95 + 28.08 + 23.76 + 8.0
= 82.79 points
```
**Rating**: Exceptional (82.79/100)
**Interpretation**: Strong CANSLIM candidate, slight weakness in historical growth
**Weakest Component**: A (78) - recovering from prior downturn
**Action**: Buy, standard position sizing (10-15% of portfolio)
---
### Example 3: Hypothetical "Average" Stock
**Component Scores:**
- **C Score**: 60 points (EPS +20% YoY - meets minimum)
- **A Score**: 55 points (3yr CAGR 26%, one down year)
- **N Score**: 65 points (12% from high, no catalyst)
- **M Score**: 60 points (S&P 500 just above EMA, early uptrend)
**Composite Calculation:**
```python
composite = (60 * 0.27) + (55 * 0.36) + (65 * 0.27) + (60 * 0.10)
= 16.2 + 19.8 + 17.55 + 6.0
= 59.55 points
```
**Rating**: Average (59.55/100)
**Interpretation**: Meets minimum thresholds but lacks conviction
**Weakest Component**: A (55) - inconsistent growth history
**Action**: Watchlist only, wait for A or N component to strengthen
---
### Example 4: Bear Market Scenario (M Score = 0)
**Component Scores:**
- **C Score**: 100 points (Excellent earnings)
- **A Score**: 90 points (Excellent growth)
- **N Score**: 95 points (New highs)
- **M Score**: 0 points (S&P 500 in bear market, VIX > 30)
**Composite Calculation:**
```python
composite = (100 * 0.27) + (90 * 0.36) + (95 * 0.27) + (0 * 0.10)
= 27.0 + 32.4 + 25.65 + 0
= 85.05 points
```
**Rating**: Exceptional fundamentals (85.05) BUT bear market
**Interpretation**: **DO NOT BUY** despite high score - market direction overrides stock quality
**Weakest Component**: M (0) - bear market environment
**Action**: Raise cash, wait for M score > 40 (market recovery signal)
**Critical Lesson**: This example illustrates O'Neil's principle: "You can be right about a stock but wrong about the market, and still lose money."
---
## Scoring Evolution History
Phase 3 (current) uses the original O'Neil weights across all 7 components. Previous phases used renormalized weights to compensate for missing components:
- **Phase 1 MVP** (4 components): C 27%, A 36%, N 27%, M 10%
- **Phase 2** (6 components): C 19%, A 25%, N 19%, S 19%, I 13%, M 6%
- **Phase 3** (7 components): C 15%, A 20%, N 15%, S 15%, L 20%, I 10%, M 5% (original O'Neil weights)
---
## Usage Notes
### For Screener Implementation
1. **Calculate all 7 component scores** (C, A, N, S, L, I, M) for each stock
2. **Apply composite formula** with Phase 3 weights
3. **Identify weakest component** for each stock
4. **Rank stocks** by composite score (highest to lowest)
5. **Apply market filter** FIRST: If M score < 40, warn user to reduce exposure
### For User Reports
**Include in output**:
- Composite score (0-100)
- Rating (Exceptional / Strong / Above Average / Average / Below Average / Weak)
- Individual component scores (C, A, N, S, L, I, M)
- Weakest component identification
- Interpretation guidance
- Recommended action (buy / watchlist / avoid)
**Format Example**:
```
NVDA - NVIDIA Corporation
Composite Score: 95.2 / 100 (Exceptional+)
Component Breakdown:
C (Current Earnings): 100 / 100 - Explosive growth (EPS +429% YoY)
A (Annual Growth): 95 / 100 - Exceptional 3yr CAGR (89%)
N (Newness): 98 / 100 - At new highs with AI catalyst
S (Supply/Demand): 85 / 100 - Strong accumulation pattern
L (Leadership): 92 / 100 - RS Rank 95, sector leader
I (Institutional): 90 / 100 - 6199 holders, 68% ownership
M (Market Direction): 100 / 100 - Strong bull market
Weakest Component: S (85) - Strong accumulation
Recommendation: Strong buy - Rare multi-bagger setup
```
---
## Validation and Testing
### Test Cases
Validate scoring system with known CANSLIM winners:
**Expected Results (Phase 1 MVP)**:
- NVDA (2023 Q2): 95-100 points (Exceptional)
- META (2023 Q3): 80-90 points (Exceptional/Strong)
- AAPL (2009 Q3): 85-95 points (Exceptional)
- TSLA (2020 Q3): 80-90 points (Exceptional)
**Expected Results for Non-CANSLIM Stocks**:
- Declining earnings stocks: C < 40 → Composite < 50
- Stocks far from highs: N < 40 → Composite < 60
- In bear markets: M = 0 → Warning generated regardless of other scores
### Scoring System Integrity Checks
1. **Range Validation**: All component scores must be 0-100
2. **Weight Validation**: Sum of weights = 100% (0.27 + 0.36 + 0.27 + 0.10 = 1.00)
3. **Monotonicity**: Higher inputs → Higher scores (linear or step-function increases)
4. **Boundary Conditions**: Test edge cases (zero EPS, negative growth, etc.)
5. **Historical Validation**: Backtest on known winners 2019-2024
---
This scoring system provides a quantitative, objective framework for implementing O'Neil's complete CANSLIM methodology. Phase 3 implements all 7 components with original O'Neil weights, providing comprehensive growth stock screening.
requirements.txt
# Runtime dependencies for standalone .skill installs (issue #330).
# Verified by scripts/check_skill_deps.py.
beautifulsoup4>=4.12 # floor verified beautifulsoup4==4.14.3 on 2026-09-05
lxml>=4.9 # parser backend selected by string literal; floor verified lxml==6.0.2 on 2026-09-05
requests>=2.31.0
scripts/calculators/earnings_calculator.py
#!/usr/bin/env python3
"""
C Component - Current Quarterly Earnings Calculator
Calculates CANSLIM 'C' component score based on quarterly EPS and revenue growth.
O'Neil's Rule: "Look for companies whose current quarterly earnings per share
are up at least 18-20% compared to the same quarter the prior year."
Scoring:
- 100 points: EPS +50%+ YoY AND Revenue +25%+ YoY (explosive growth)
- 80 points: EPS +30-49% AND Revenue +15%+ (strong growth)
- 60 points: EPS +18-29% AND Revenue +10%+ (meets CANSLIM minimum)
- 40 points: EPS +10-17% (below threshold)
- 0 points: EPS <10% or negative
"""
def calculate_quarterly_growth(income_statements: list[dict]) -> dict:
"""
Calculate quarterly EPS and revenue growth (year-over-year)
Args:
income_statements: List of quarterly income statements from FMP API
(most recent quarter first, needs at least 5 quarters)
Returns:
Dict with:
- score: 0-100 points
- latest_qtr_eps_growth: YoY EPS growth percentage
- latest_qtr_revenue_growth: YoY revenue growth percentage
- latest_eps: Most recent quarterly EPS
- year_ago_eps: EPS from same quarter last year
- latest_revenue: Most recent quarterly revenue
- year_ago_revenue: Revenue from same quarter last year
- interpretation: Human-readable interpretation
- error: Error message if calculation failed
Example:
>>> income_stmts = client.get_income_statement("NVDA", period="quarter", limit=8)
>>> result = calculate_quarterly_growth(income_stmts)
>>> print(f"C Score: {result['score']}, EPS Growth: {result['latest_qtr_eps_growth']:.1f}%")
"""
# Validate input
if not income_statements or len(income_statements) < 5:
return {
"score": 0,
"error": "Insufficient quarterly data (need at least 5 quarters for YoY comparison)",
"latest_qtr_eps_growth": None,
"latest_qtr_revenue_growth": None,
"latest_eps": None,
"year_ago_eps": None,
"latest_revenue": None,
"year_ago_revenue": None,
"interpretation": "Data unavailable",
}
# Extract most recent quarter (index 0) and year-ago quarter (index 4)
latest = income_statements[0]
year_ago = income_statements[4]
# Extract EPS (try multiple field names for compatibility)
latest_eps = latest.get("eps") or latest.get("epsdiluted") or latest.get("netIncomePerShare")
year_ago_eps = (
year_ago.get("eps") or year_ago.get("epsdiluted") or year_ago.get("netIncomePerShare")
)
# Extract revenue
latest_revenue = latest.get("revenue")
year_ago_revenue = year_ago.get("revenue")
# Validate extracted data
if latest_eps is None or year_ago_eps is None:
return {
"score": 0,
"error": "EPS data missing or invalid",
"latest_qtr_eps_growth": None,
"latest_qtr_revenue_growth": None,
"latest_eps": latest_eps,
"year_ago_eps": year_ago_eps,
"latest_revenue": latest_revenue,
"year_ago_revenue": year_ago_revenue,
"interpretation": "EPS data unavailable",
}
if latest_revenue is None or year_ago_revenue is None or year_ago_revenue == 0:
return {
"score": 0,
"error": "Revenue data missing or invalid",
"latest_qtr_eps_growth": None,
"latest_qtr_revenue_growth": None,
"latest_eps": latest_eps,
"year_ago_eps": year_ago_eps,
"latest_revenue": latest_revenue,
"year_ago_revenue": year_ago_revenue,
"interpretation": "Revenue data unavailable",
}
# Calculate year-over-year growth
# Use abs() for denominator to handle negative EPS (turnaround situations)
if year_ago_eps == 0:
# Handle zero/negative EPS edge case
if latest_eps > 0 and year_ago_eps <= 0:
# Turnaround situation (negative to positive)
eps_growth = 999.9 # Cap at very high growth
else:
eps_growth = 0
else:
eps_growth = ((latest_eps - year_ago_eps) / abs(year_ago_eps)) * 100
revenue_growth = ((latest_revenue - year_ago_revenue) / year_ago_revenue) * 100
# Calculate score
score = score_current_earnings(eps_growth, revenue_growth)
# Generate interpretation
interpretation = interpret_earnings_score(score, eps_growth, revenue_growth)
# Quality check: flag if revenue growth significantly lags EPS growth
quality_warning = None
if revenue_growth < (eps_growth * 0.5) and eps_growth > 20:
quality_warning = (
"Revenue growth significantly lags EPS growth - "
"investigate earnings quality (potential buyback-driven)"
)
return {
"score": score,
"latest_qtr_eps_growth": round(eps_growth, 1),
"latest_qtr_revenue_growth": round(revenue_growth, 1),
"latest_eps": latest_eps,
"year_ago_eps": year_ago_eps,
"latest_revenue": latest_revenue,
"year_ago_revenue": year_ago_revenue,
"latest_qtr_date": latest.get("date"),
"year_ago_qtr_date": year_ago.get("date"),
"interpretation": interpretation,
"quality_warning": quality_warning,
"error": None,
}
def score_current_earnings(eps_growth: float, revenue_growth: float) -> int:
"""
Score C component based on quarterly EPS and revenue growth
Args:
eps_growth: Year-over-year EPS growth percentage
revenue_growth: Year-over-year revenue growth percentage
Returns:
Score (0-100)
Scoring Logic (from scoring_system.md):
- 100: EPS >=50% AND Revenue >=25% (explosive)
- 80: EPS 30-49% AND Revenue >=15% (strong)
- 60: EPS 18-29% AND Revenue >=10% (meets CANSLIM minimum)
- 40: EPS 10-17% (below threshold)
- 0: EPS <10% (weak/negative)
"""
# Exceptional growth
if eps_growth >= 50 and revenue_growth >= 25:
return 100
# Strong growth
if eps_growth >= 30 and revenue_growth >= 15:
return 80
# Meets CANSLIM minimum (18%+ EPS growth)
if eps_growth >= 18 and revenue_growth >= 10:
return 60
# Below threshold but positive
if eps_growth >= 10:
return 40
# Weak or negative growth
return 0
def interpret_earnings_score(score: int, eps_growth: float, revenue_growth: float) -> str:
"""
Generate human-readable interpretation of C component score
Args:
score: Component score (0-100)
eps_growth: YoY EPS growth percentage
revenue_growth: YoY revenue growth percentage
Returns:
Interpretation string
"""
if score >= 90:
return (
f"Exceptional - Explosive earnings acceleration "
f"(EPS +{eps_growth:.1f}%, Revenue +{revenue_growth:.1f}%)"
)
elif score >= 70:
return (
f"Strong - Well above CANSLIM threshold "
f"(EPS +{eps_growth:.1f}%, Revenue +{revenue_growth:.1f}%)"
)
elif score >= 50:
return (
f"Acceptable - Meets CANSLIM minimum 18% threshold "
f"(EPS +{eps_growth:.1f}%, Revenue +{revenue_growth:.1f}%)"
)
elif score >= 30:
return (
f"Below threshold - Insufficient growth "
f"(EPS +{eps_growth:.1f}%, Revenue +{revenue_growth:.1f}%)"
)
else:
return (
f"Weak - Does not meet CANSLIM criteria "
f"(EPS {eps_growth:+.1f}%, Revenue {revenue_growth:+.1f}%)"
)
def detect_earnings_acceleration(income_statements: list[dict]) -> dict:
"""
Detect if earnings are accelerating or decelerating (trend analysis)
Args:
income_statements: List of quarterly income statements (recent first)
Returns:
Dict with:
- trend: "accelerating", "stable", or "decelerating"
- recent_growth: Most recent quarter YoY growth
- prior_growth: Prior quarter YoY growth
- interpretation: Trend description
Note:
Earnings acceleration (recent > prior) is bullish signal per O'Neil.
Deceleration is early warning of potential weakness.
"""
if len(income_statements) < 6:
return {
"trend": "unknown",
"recent_growth": None,
"prior_growth": None,
"interpretation": "Insufficient data for trend analysis",
}
# Most recent quarter vs year-ago
recent_eps = income_statements[0].get("eps", 0)
recent_year_ago_eps = income_statements[4].get("eps", 0.01)
recent_growth = (
((recent_eps - recent_year_ago_eps) / abs(recent_year_ago_eps)) * 100
if recent_year_ago_eps
else 0
)
# Prior quarter vs its year-ago
prior_eps = income_statements[1].get("eps", 0)
prior_year_ago_eps = income_statements[5].get("eps", 0.01)
prior_growth = (
((prior_eps - prior_year_ago_eps) / abs(prior_year_ago_eps)) * 100
if prior_year_ago_eps
else 0
)
# Determine trend
if recent_growth > prior_growth + 5: # 5% threshold for significance
trend = "accelerating"
interpretation = (
f"Earnings accelerating ({recent_growth:.1f}% vs {prior_growth:.1f}% prior quarter)"
)
elif recent_growth < prior_growth - 5:
trend = "decelerating"
interpretation = f"Earnings decelerating ({recent_growth:.1f}% vs {prior_growth:.1f}% prior quarter) - Warning sign"
else:
trend = "stable"
interpretation = (
f"Earnings stable ({recent_growth:.1f}% vs {prior_growth:.1f}% prior quarter)"
)
return {
"trend": trend,
"recent_growth": round(recent_growth, 1),
"prior_growth": round(prior_growth, 1),
"interpretation": interpretation,
}
# Example usage and testing
if __name__ == "__main__":
print("Testing Earnings Calculator (C Component)...\n")
# Test case 1: Exceptional growth (should score 100)
test_data_exceptional = [
{"date": "2023-06-30", "eps": 2.70, "revenue": 13507000000}, # Q2 2023
{"date": "2023-03-31", "eps": 1.09, "revenue": 7192000000},
{"date": "2022-12-31", "eps": 0.88, "revenue": 6051000000},
{"date": "2022-09-30", "eps": 0.58, "revenue": 5931000000},
{"date": "2022-06-30", "eps": 0.51, "revenue": 6704000000}, # Q2 2022 (year-ago)
]
result1 = calculate_quarterly_growth(test_data_exceptional)
print("Test 1: Exceptional Growth (NVDA-like)")
print(f" Score: {result1['score']}/100")
print(f" EPS Growth: {result1['latest_qtr_eps_growth']}%")
print(f" Revenue Growth: {result1['latest_qtr_revenue_growth']}%")
print(f" Interpretation: {result1['interpretation']}\n")
# Test case 2: Meets minimum (should score 60)
test_data_minimum = [
{"date": "2023-06-30", "eps": 1.20, "revenue": 10500000000},
{"date": "2023-03-31", "eps": 1.15, "revenue": 10200000000},
{"date": "2022-12-31", "eps": 1.10, "revenue": 10000000000},
{"date": "2022-09-30", "eps": 1.05, "revenue": 9800000000},
{"date": "2022-06-30", "eps": 1.00, "revenue": 9500000000}, # +20% EPS, +10.5% revenue
]
result2 = calculate_quarterly_growth(test_data_minimum)
print("Test 2: Meets CANSLIM Minimum")
print(f" Score: {result2['score']}/100")
print(f" EPS Growth: {result2['latest_qtr_eps_growth']}%")
print(f" Revenue Growth: {result2['latest_qtr_revenue_growth']}%")
print(f" Interpretation: {result2['interpretation']}\n")
# Test case 3: Below threshold (should score 40)
test_data_weak = [
{"date": "2023-06-30", "eps": 1.12, "revenue": 10200000000},
{"date": "2023-03-31", "eps": 1.08, "revenue": 10100000000},
{"date": "2022-12-31", "eps": 1.05, "revenue": 10000000000},
{"date": "2022-09-30", "eps": 1.02, "revenue": 9900000000},
{"date": "2022-06-30", "eps": 1.00, "revenue": 9800000000}, # +12% EPS, +4% revenue
]
result3 = calculate_quarterly_growth(test_data_weak)
print("Test 3: Below Threshold")
print(f" Score: {result3['score']}/100")
print(f" EPS Growth: {result3['latest_qtr_eps_growth']}%")
print(f" Revenue Growth: {result3['latest_qtr_revenue_growth']}%")
print(f" Interpretation: {result3['interpretation']}\n")
# Test case 4: Turnaround (negative to positive EPS)
test_data_turnaround = [
{"date": "2023-06-30", "eps": 0.50, "revenue": 10000000000},
{"date": "2023-03-31", "eps": 0.20, "revenue": 9500000000},
{"date": "2022-12-31", "eps": -0.10, "revenue": 9000000000},
{"date": "2022-09-30", "eps": -0.30, "revenue": 8500000000},
{"date": "2022-06-30", "eps": -0.40, "revenue": 8000000000}, # Turnaround situation
]
result4 = calculate_quarterly_growth(test_data_turnaround)
print("Test 4: Turnaround (Negative to Positive)")
print(f" Score: {result4['score']}/100")
print(f" EPS Growth: {result4['latest_qtr_eps_growth']}%")
print(f" Revenue Growth: {result4['latest_qtr_revenue_growth']}%")
print(f" Interpretation: {result4['interpretation']}\n")
# Test acceleration detection
print("Test 5: Acceleration Detection")
accel = detect_earnings_acceleration(test_data_exceptional)
print(f" Trend: {accel['trend']}")
print(f" Recent growth: {accel['recent_growth']}%")
print(f" Prior growth: {accel['prior_growth']}%")
print(f" Interpretation: {accel['interpretation']}")
print("\n✓ All tests completed")
scripts/calculators/growth_calculator.py
#!/usr/bin/env python3
"""
A Component - Annual EPS Growth Calculator
Calculates CANSLIM 'A' component score based on multi-year EPS CAGR and growth stability.
O'Neil's Rule: "Annual earnings per share should be up 25% or more in each
of the last three years."
Scoring:
- 90-100: CAGR 40%+, stable growth (no down years)
- 70-89: CAGR 30-39%, stable
- 50-69: CAGR 25-29% (meets CANSLIM minimum)
- 30-49: CAGR 15-24% (below threshold)
- 0-29: CAGR <15% or erratic growth
Bonus: +10 points for stability (no down years)
Penalty: -20% if revenue CAGR significantly lags EPS CAGR (buyback-driven growth)
"""
def calculate_annual_growth(income_statements: list[dict]) -> dict:
"""
Calculate 3-year EPS CAGR and growth stability
Args:
income_statements: List of annual income statements from FMP API
(most recent year first, needs at least 4 years for 3-year CAGR)
Returns:
Dict with:
- score: 0-100 points
- eps_cagr_3yr: 3-year EPS compound annual growth rate (%)
- revenue_cagr_3yr: 3-year revenue CAGR (%) for validation
- stability: "stable" or "erratic"
- eps_values: List of EPS values (chronological)
- interpretation: Human-readable interpretation
- error: Error message if calculation failed
"""
# Validate input
if not income_statements or len(income_statements) < 4:
return {
"score": 50, # Default for insufficient data (neutral)
"error": "Insufficient annual data (need at least 4 years for 3-year CAGR)",
"eps_cagr_3yr": None,
"revenue_cagr_3yr": None,
"stability": "unknown",
"eps_values": None,
"interpretation": "Data unavailable",
}
# Extract EPS for last 4 years (most recent first in API response)
eps_values = []
revenue_values = []
for i in range(4):
stmt = income_statements[i]
eps = stmt.get("eps") or stmt.get("epsdiluted")
revenue = stmt.get("revenue")
if eps is None:
return {
"score": 0,
"error": f"Missing EPS data for year {stmt.get('date', 'unknown')}",
"eps_cagr_3yr": None,
"revenue_cagr_3yr": None,
"stability": "unknown",
"eps_values": None,
"interpretation": "EPS data incomplete",
}
if eps <= 0:
return {
"score": 0,
"error": f"Negative or zero EPS in year {stmt.get('date', 'unknown')} - cannot calculate CAGR",
"eps_cagr_3yr": None,
"revenue_cagr_3yr": None,
"stability": "unknown",
"eps_values": None,
"interpretation": "Negative EPS - not a CANSLIM candidate",
}
eps_values.append(eps)
revenue_values.append(revenue if revenue else 0)
# Reverse to chronological order (oldest first)
eps_values_chrono = eps_values[::-1]
revenue_values_chrono = revenue_values[::-1]
# Calculate 3-year CAGR
# CAGR = ((Ending Value / Beginning Value) ^ (1 / Number of Years)) - 1
eps_start = eps_values_chrono[0] # 3 years ago
eps_end = eps_values_chrono[3] # Current year
years = 3
eps_cagr = (((eps_end / eps_start) ** (1 / years)) - 1) * 100
# Calculate revenue CAGR for validation
revenue_start = revenue_values_chrono[0]
revenue_end = revenue_values_chrono[3]
if revenue_start > 0:
revenue_cagr = (((revenue_end / revenue_start) ** (1 / years)) - 1) * 100
else:
revenue_cagr = 0
# Check growth stability (no down years)
stable = all(
eps_values_chrono[i] >= eps_values_chrono[i - 1] for i in range(1, len(eps_values_chrono))
)
# Calculate score
score = score_annual_growth(eps_cagr, revenue_cagr, stable)
# Generate interpretation
interpretation = interpret_growth_score(score, eps_cagr, stable)
# Quality warning
quality_warning = None
if revenue_cagr < (eps_cagr * 0.5) and eps_cagr > 20:
quality_warning = (
"Revenue CAGR significantly lags EPS CAGR - "
"growth may be buyback-driven rather than organic"
)
return {
"score": score,
"eps_cagr_3yr": round(eps_cagr, 1),
"revenue_cagr_3yr": round(revenue_cagr, 1),
"stability": "stable" if stable else "erratic",
"eps_values": [round(eps, 2) for eps in eps_values_chrono],
"revenue_values": [int(rev) for rev in revenue_values_chrono],
"years_analyzed": len(eps_values_chrono),
"interpretation": interpretation,
"quality_warning": quality_warning,
"error": None,
}
def score_annual_growth(eps_cagr: float, revenue_cagr: float, stable: bool) -> int:
"""
Score A component based on EPS CAGR, revenue validation, and stability
Args:
eps_cagr: 3-year EPS compound annual growth rate (%)
revenue_cagr: 3-year revenue CAGR (%)
stable: True if no down years, False if erratic
Returns:
Score (0-100)
Scoring Logic:
- Base score from EPS CAGR:
- 40%+: 90 points
- 30-39%: 70 points
- 25-29%: 50 points (meets CANSLIM minimum)
- 15-24%: 30 points
- <15%: 0 points
- Penalty: -20% if revenue CAGR < 50% of EPS CAGR (buyback concern)
- Bonus: +10 points if stable growth (no down years)
"""
# Base score from EPS CAGR
if eps_cagr >= 40:
base_score = 90
elif eps_cagr >= 30:
base_score = 70
elif eps_cagr >= 25:
base_score = 50 # Meets CANSLIM minimum
elif eps_cagr >= 15:
base_score = 30
else:
base_score = 0
# Revenue growth validation penalty
if revenue_cagr < (eps_cagr * 0.5):
base_score = int(base_score * 0.8) # 20% penalty
# Stability bonus
if stable:
base_score += 10
# Cap at 100
return min(base_score, 100)
def interpret_growth_score(score: int, eps_cagr: float, stable: bool) -> str:
"""
Generate human-readable interpretation of A component score
Args:
score: Component score (0-100)
eps_cagr: 3-year EPS CAGR (%)
stable: Growth stability flag
Returns:
Interpretation string
"""
stability_text = "stable" if stable else "erratic"
if score >= 90:
return f"Exceptional - {eps_cagr:.1f}% CAGR, {stability_text} growth trajectory"
elif score >= 70:
return f"Strong - {eps_cagr:.1f}% CAGR, well above CANSLIM 25% threshold ({stability_text})"
elif score >= 50:
return f"Acceptable - {eps_cagr:.1f}% CAGR meets CANSLIM minimum ({stability_text})"
elif score >= 30:
return f"Below threshold - {eps_cagr:.1f}% CAGR insufficient (<25% required)"
else:
return f"Weak - {eps_cagr:.1f}% CAGR does not meet CANSLIM criteria"
def check_consistency(income_statements: list[dict]) -> dict:
"""
Check year-over-year growth consistency (no down years is ideal)
Args:
income_statements: List of annual income statements (recent first)
Returns:
Dict with:
- down_years: Count of years with YoY decline
- consecutive_growth_years: Max consecutive years of growth
- growth_pattern: List of YoY growth rates
"""
if len(income_statements) < 2:
return {
"down_years": None,
"consecutive_growth_years": None,
"growth_pattern": None,
"interpretation": "Insufficient data",
}
eps_values = []
for stmt in income_statements:
eps = stmt.get("eps") or stmt.get("epsdiluted")
if eps:
eps_values.append(eps)
# Reverse to chronological order
eps_values = eps_values[::-1]
# Calculate YoY growth rates
growth_pattern = []
for i in range(1, len(eps_values)):
if eps_values[i - 1] > 0:
yoy_growth = ((eps_values[i] - eps_values[i - 1]) / eps_values[i - 1]) * 100
growth_pattern.append(round(yoy_growth, 1))
# Count down years
down_years = sum(1 for g in growth_pattern if g < 0)
# Find max consecutive growth years
consecutive = 0
max_consecutive = 0
for g in growth_pattern:
if g >= 0:
consecutive += 1
max_consecutive = max(max_consecutive, consecutive)
else:
consecutive = 0
interpretation = f"{max_consecutive} consecutive years of growth, {down_years} down years"
return {
"down_years": down_years,
"consecutive_growth_years": max_consecutive,
"growth_pattern": growth_pattern,
"interpretation": interpretation,
}
# Example usage and testing
if __name__ == "__main__":
print("Testing Growth Calculator (A Component)...\n")
# Test case 1: Exceptional growth (NVDA-like, 40%+ CAGR, stable)
test_data_exceptional = [
{"date": "2023-01-31", "eps": 4.02, "revenue": 26974000000}, # FY2023
{"date": "2022-01-31", "eps": 4.44, "revenue": 26914000000}, # FY2022
{"date": "2021-01-31", "eps": 1.93, "revenue": 16675000000}, # FY2021
{"date": "2020-01-31", "eps": 1.67, "revenue": 10918000000}, # FY2020
]
result1 = calculate_annual_growth(test_data_exceptional)
print("Test 1: Exceptional Growth (40%+ CAGR)")
print(f" Score: {result1['score']}/100")
print(f" EPS CAGR: {result1['eps_cagr_3yr']}%")
print(f" Revenue CAGR: {result1['revenue_cagr_3yr']}%")
print(f" Stability: {result1['stability']}")
print(f" Interpretation: {result1['interpretation']}\n")
# Test case 2: Meets minimum (25-29% CAGR, stable)
test_data_minimum = [
{"date": "2023-12-31", "eps": 2.00, "revenue": 50000000000},
{"date": "2022-12-31", "eps": 1.60, "revenue": 45000000000},
{"date": "2021-12-31", "eps": 1.28, "revenue": 40000000000},
{"date": "2020-12-31", "eps": 1.02, "revenue": 36000000000}, # ~25% CAGR
]
result2 = calculate_annual_growth(test_data_minimum)
print("Test 2: Meets CANSLIM Minimum (25% CAGR)")
print(f" Score: {result2['score']}/100")
print(f" EPS CAGR: {result2['eps_cagr_3yr']}%")
print(f" Revenue CAGR: {result2['revenue_cagr_3yr']}%")
print(f" Stability: {result2['stability']}")
print(f" Interpretation: {result2['interpretation']}\n")
# Test case 3: Erratic growth (one down year)
test_data_erratic = [
{"date": "2023-12-31", "eps": 2.50, "revenue": 50000000000},
{"date": "2022-12-31", "eps": 1.80, "revenue": 45000000000}, # Down year
{"date": "2021-12-31", "eps": 2.00, "revenue": 48000000000},
{"date": "2020-12-31", "eps": 1.60, "revenue": 42000000000},
]
result3 = calculate_annual_growth(test_data_erratic)
print("Test 3: Erratic Growth (One Down Year)")
print(f" Score: {result3['score']}/100")
print(f" EPS CAGR: {result3['eps_cagr_3yr']}%")
print(f" Stability: {result3['stability']}")
print(f" Interpretation: {result3['interpretation']}\n")
# Test consistency check
print("Test 4: Consistency Check")
consistency = check_consistency(test_data_exceptional)
print(f" Down years: {consistency['down_years']}")
print(f" Consecutive growth: {consistency['consecutive_growth_years']} years")
print(f" Growth pattern: {consistency['growth_pattern']}")
print(f" Interpretation: {consistency['interpretation']}")
print("\n✓ All tests completed")
scripts/calculators/institutional_calculator.py
#!/usr/bin/env python3
"""
I Component - Institutional Sponsorship Calculator (Full Implementation)
Calculates CANSLIM 'I' component score based on institutional holder count and ownership percentage.
O'Neil's Rule: "You need some of the big boys on your side. Look for stocks with increasing
institutional sponsorship, but not too much. The sweet spot is 50-100 institutional holders
with 30-60% ownership."
Key Principles:
- 50-100 holders: Enough interest, not overcrowded
- 30-60% ownership: Strong backing without lock-up
- <30%: Underowned (potential upside, but may lack institutional support)
- >80%: Overcrowded (no buying power left, vulnerable to selling)
- Superinvestors (Berkshire, Baupost, etc.): Quality signal
Scoring:
- 100 points: 50-100 holders + 30-60% ownership (O'Neil's sweet spot)
- 90 points: Superinvestor present + good holder count
- 80 points: 30-50 holders + 20-40% ownership OR 100-150 holders + 40-70% ownership
- 60 points: Acceptable but suboptimal ranges
- 40 points: <20% or >80% ownership
- 20 points: <10% or >90% ownership
- 0 points: Institutional data unavailable or extreme ranges
"""
import os
import sys
from typing import Optional
# Optional: Import Finviz client for fallback data (requires finviz library)
try:
# Add parent directory to path to import finviz_stock_client
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from finviz_stock_client import FinvizStockClient
FINVIZ_AVAILABLE = True
except ImportError:
FINVIZ_AVAILABLE = False
print(
"INFO: finviz library not available. Install with 'pip install finviz' for improved institutional data.",
file=sys.stderr,
)
# List of superinvestors (legendary value investors whose presence signals quality)
SUPERINVESTORS = [
"BERKSHIRE HATHAWAY",
"BAUPOST GROUP",
"PERSHING SQUARE",
"GREENLIGHT CAPITAL",
"THIRD POINT",
"APPALOOSA MANAGEMENT",
"VIKING GLOBAL",
"SCION ASSET MANAGEMENT", # Michael Burry
"BRIDGEWATER ASSOCIATES", # Ray Dalio
"RENAISSANCE TECHNOLOGIES",
]
def calculate_institutional_sponsorship(
institutional_holders: list[dict],
profile: Optional[dict] = None,
symbol: Optional[str] = None,
use_finviz_fallback: bool = True,
) -> dict:
"""
Calculate institutional sponsorship score (Full Implementation with Finviz fallback)
Args:
institutional_holders: List of institutional holders from FMP API
Each entry: {"holder": str, "shares": int, "dateReported": str, "change": int}
profile: Company profile dict (optional, for shares outstanding)
symbol: Stock ticker symbol (optional, required for Finviz fallback)
use_finviz_fallback: If True, use Finviz data when FMP ownership % unavailable (default: True)
Returns:
Dict with:
- score: 0-100 points
- num_holders: Number of institutional holders
- ownership_pct: Institutional ownership percentage
- superinvestor_present: True if superinvestor detected
- superinvestors: List of superinvestors holding the stock
- total_shares_held: Total shares held by institutions
- shares_outstanding: Total shares outstanding (from profile)
- interpretation: Human-readable interpretation
- quality_warning: Warning if data quality issues
- error: Error message if calculation failed
Example:
>>> holders = client.get_institutional_holders("NVDA")
>>> profile = client.get_profile("NVDA")[0]
>>> result = calculate_institutional_sponsorship(holders, profile)
>>> print(f"I Score: {result['score']}, Holders: {result['num_holders']}, Ownership: {result['ownership_pct']:.1f}%")
"""
missing_holder_input = institutional_holders is None or institutional_holders == []
# Treat missing FMP data as "zero holders" instead of returning early — the
# Finviz fallback below (line ~162) can still recover ownership_pct in many
# cases. This matters for FMP free-tier users: /stable institutional-
# ownership endpoints return 402 "Restricted" and the legacy /api/v3
# institutional-holder endpoint returns 403 "Legacy" — get_institutional_
# holders() correctly returns None, but the old early-return killed the
# whole 'I' component (always score=0) before Finviz could fill the gap.
if not institutional_holders:
institutional_holders = []
# Accept either the /stable aggregate shape (dict from get_institutional_holders)
# or a legacy v3 holder list.
if isinstance(institutional_holders, dict):
num_holders = institutional_holders.get("num_holders") or 0
preset_ownership_pct = institutional_holders.get("ownership_pct")
holder_list = institutional_holders.get("top_holders") or []
else:
num_holders = len(institutional_holders)
preset_ownership_pct = None
holder_list = institutional_holders
# Check for superinvestors (the top holders carry the names)
superinvestors_found = []
for holder_entry in holder_list:
holder_name = (holder_entry.get("holder") or "").upper()
for superinvestor in SUPERINVESTORS:
if superinvestor in holder_name:
superinvestors_found.append(holder_entry.get("holder"))
break
superinvestor_present = len(superinvestors_found) > 0
# Total shares held (used only for the list-derived ownership path).
total_shares_held = sum(holder.get("shares", 0) or 0 for holder in holder_list)
# Ownership %: prefer the /stable summary value; else derive from shares +
# shares outstanding; else fall back to Finviz.
ownership_pct = preset_ownership_pct
shares_outstanding = None
quality_warning = ""
data_source = "FMP" # Track data source
if ownership_pct is None and profile:
# Try to get shares outstanding (different field names possible)
shares_outstanding = profile.get("sharesOutstanding")
# Fallback: calculate from market cap and price
if not shares_outstanding:
market_cap = profile.get("mktCap") or profile.get("marketCap")
price = profile.get("price")
if market_cap and price and price > 0:
shares_outstanding = market_cap / price
# Only derive ownership_pct from the holder list if there IS a holder
# list. total_shares_held == 0 means the FMP /stable institutional
# endpoints returned nothing (free-tier 402 → fallback to v3 403 →
# get_institutional_holders returns None → empty list here). Computing
# 0 / shares_outstanding × 100 = 0.0 in that case would falsely set
# ownership_pct to 0.0, blocking the Finviz fallback below
# (which gates on `ownership_pct is None`). Leave it None so Finviz
# can recover the real value.
if shares_outstanding and shares_outstanding > 0 and total_shares_held > 0:
ownership_pct = (total_shares_held / shares_outstanding) * 100
elif not shares_outstanding:
quality_warning = "Shares outstanding unavailable from FMP."
elif ownership_pct is None:
quality_warning = "Company profile not provided."
# Finviz fallback: If ownership % still unavailable, try Finviz
if ownership_pct is None and use_finviz_fallback and FINVIZ_AVAILABLE and symbol:
try:
finviz_client = FinvizStockClient(rate_limit_seconds=1.0)
finviz_data = finviz_client.get_institutional_ownership(symbol)
if finviz_data and finviz_data.get("inst_own_pct") is not None:
ownership_pct = finviz_data["inst_own_pct"]
data_source = "Finviz"
quality_warning = f"Using Finviz institutional ownership data ({ownership_pct:.1f}%) - FMP shares outstanding unavailable."
print(
f"✅ Using Finviz institutional ownership for {symbol}: {ownership_pct:.1f}%",
file=sys.stderr,
)
else:
quality_warning += " Finviz fallback also unavailable. Score reduced by 50%."
except Exception as e:
print(f"WARNING: Finviz fallback failed for {symbol}: {e}", file=sys.stderr)
quality_warning += " Finviz fallback failed. Score reduced by 50%."
# Final warning if ownership % still unavailable
if ownership_pct is None:
if not quality_warning:
quality_warning = "Ownership % unavailable from all sources. Score reduced by 50%."
else:
quality_warning += " Score reduced by 50%."
# Preserve the original no-data semantics when both FMP and Finviz are
# unavailable, but do not zero valid partial FMP aggregate data.
if missing_holder_input and ownership_pct is None:
score = 0
quality_warning = (
"Institutional holder data unavailable from FMP and Finviz. Score set to 0."
)
else:
score = score_institutional_sponsorship(
num_holders, ownership_pct, superinvestor_present, quality_warning
)
# Generate interpretation
interpretation = interpret_institutional_sponsorship(
num_holders, ownership_pct, superinvestor_present, superinvestors_found
)
return {
"score": score,
"num_holders": num_holders,
"ownership_pct": ownership_pct,
"superinvestor_present": superinvestor_present,
"superinvestors": superinvestors_found,
"total_shares_held": total_shares_held,
"shares_outstanding": shares_outstanding,
"interpretation": interpretation,
"quality_warning": quality_warning,
"data_source": data_source, # "FMP" or "Finviz"
}
def score_institutional_sponsorship(
num_holders: int,
ownership_pct: Optional[float],
superinvestor_present: bool,
quality_warning: Optional[str],
) -> int:
"""
Score institutional sponsorship based on O'Neil's criteria
Args:
num_holders: Number of institutional holders
ownership_pct: Institutional ownership percentage (None if unavailable)
superinvestor_present: True if superinvestor detected
quality_warning: Data quality warning
Returns:
int: Score from 0-100
"""
# If ownership % unavailable, score on holder count only (reduced scale)
if ownership_pct is None:
if num_holders >= 50 and num_holders <= 100:
base_score = 50 # Half of ideal score
elif num_holders >= 30 and num_holders < 50:
base_score = 40
elif num_holders > 100 and num_holders <= 150:
base_score = 35
elif num_holders > 150:
base_score = 20
else:
base_score = 10
# Superinvestor bonus (even without ownership %)
if superinvestor_present:
base_score += 15
return min(base_score, 70) # Cap at 70 when ownership % unavailable
# Full scoring with ownership %
base_score = 0
# O'Neil's Sweet Spot: 50-100 holders + 30-60% ownership
if 50 <= num_holders <= 100 and 30 <= ownership_pct <= 60:
base_score = 100
# Superinvestor present + good holder count
elif superinvestor_present and 30 <= num_holders <= 150:
base_score = 90
# Good ranges (slightly outside sweet spot)
elif (30 <= num_holders < 50 and 20 <= ownership_pct <= 40) or (
100 < num_holders <= 150 and 40 <= ownership_pct <= 70
):
base_score = 80
# Acceptable ranges
elif (20 <= num_holders < 30 and 20 <= ownership_pct <= 50) or (
50 <= num_holders <= 150 and 20 <= ownership_pct <= 70
):
base_score = 60
# Extreme ownership (check narrower range first)
elif ownership_pct < 10 or ownership_pct > 90:
base_score = 20
# Suboptimal ownership
elif ownership_pct < 20 or ownership_pct > 80:
base_score = 40
else:
base_score = 50 # Default for other combinations
# Superinvestor bonus
if superinvestor_present and base_score < 100:
base_score = min(base_score + 10, 100)
return base_score
def interpret_institutional_sponsorship(
num_holders: int,
ownership_pct: Optional[float],
superinvestor_present: bool,
superinvestors: list[str],
) -> str:
"""
Generate human-readable interpretation
Args:
num_holders: Number of institutional holders
ownership_pct: Institutional ownership percentage
superinvestor_present: True if superinvestor detected
superinvestors: List of superinvestor names
Returns:
str: Interpretation string
"""
# Holder count assessment
if 50 <= num_holders <= 100:
holder_msg = f"{num_holders} holders (O'Neil's sweet spot)"
elif 30 <= num_holders < 50:
holder_msg = f"{num_holders} holders (good, but could grow)"
elif num_holders > 100 and num_holders <= 150:
holder_msg = f"{num_holders} holders (getting crowded)"
elif num_holders > 150:
holder_msg = f"{num_holders} holders (overcrowded)"
else:
holder_msg = f"{num_holders} holders (underowned)"
# Ownership assessment
if ownership_pct is None:
ownership_msg = "ownership % unavailable"
elif 30 <= ownership_pct <= 60:
ownership_msg = f"{ownership_pct:.1f}% ownership (ideal range)"
elif 20 <= ownership_pct < 30 or 60 < ownership_pct <= 80:
ownership_msg = f"{ownership_pct:.1f}% ownership (acceptable)"
elif ownership_pct < 20:
ownership_msg = f"{ownership_pct:.1f}% ownership (underowned, potential upside)"
else:
ownership_msg = f"{ownership_pct:.1f}% ownership (overcrowded, vulnerable)"
# Superinvestor note
if superinvestor_present:
superinvestor_names = ", ".join(superinvestors[:2]) # Show first 2
if len(superinvestors) > 2:
superinvestor_names += f" +{len(superinvestors) - 2} more"
superinvestor_msg = f" ⭐ Superinvestors: {superinvestor_names}"
else:
superinvestor_msg = ""
return f"{holder_msg}, {ownership_msg}.{superinvestor_msg}"
# Example usage
if __name__ == "__main__":
# Test with sample data
sample_holders = [
{
"holder": "Vanguard Group Inc",
"shares": 1000000000,
"dateReported": "2024-09-30",
"change": 5000000,
},
{
"holder": "BERKSHIRE HATHAWAY INC",
"shares": 500000000,
"dateReported": "2024-09-30",
"change": 0,
},
] + [
{
"holder": f"Institution {i}",
"shares": 10000000,
"dateReported": "2024-09-30",
"change": 0,
}
for i in range(3, 75) # Total 74 holders
]
sample_profile = {"symbol": "TEST", "sharesOutstanding": 5000000000, "price": 150.0}
result = calculate_institutional_sponsorship(sample_holders, sample_profile)
print("I Component Test Results:")
print(f" Score: {result['score']}/100")
print(f" Holders: {result['num_holders']}")
print(f" Ownership: {result['ownership_pct']:.1f}%")
print(f" Superinvestor: {result['superinvestor_present']}")
print(f" Superinvestors: {result['superinvestors']}")
print(f" Interpretation: {result['interpretation']}")
scripts/calculators/leadership_calculator.py
#!/usr/bin/env python3
"""
L Component - Leadership / Relative Strength Calculator (Phase 3.1)
Calculates CANSLIM 'L' component score based on multi-period weighted relative
price performance vs a configurable benchmark (default: ^GSPC).
O'Neil's Rule: "The top-performing stocks, prior to major advances, have Relative Strength
Ratings averaging 87 at pivot points. You want to see 80+ RS Rank - meaning the stock is
outperforming 80% of all other stocks over the past 52 weeks."
Phase 3.1 Multi-Period RS:
- Three trailing periods (3m=63 bars, 6m=126 bars, 12m=252 bars) instead of a single 52w slice.
- Weighted RS = 0.40 * rel_3m + 0.30 * rel_6m + 0.30 * rel_12m
(When some periods are missing, the available periods are re-normalized so weights sum to 1.0.)
- Benchmark is configurable via the rs_benchmark argument (defaults to ^GSPC).
Backward Compatibility:
- The legacy fields (stock_52w_performance, sp500_52w_performance, relative_performance,
rs_rank_estimate) are preserved with their original semantics (365-day full-window
return). Existing callers and JSON consumers continue to work.
- The legacy sp500_performance argument (pre-calculated benchmark return) routes to a
single-period scoring path; multi-period fields are then None.
Score Bands (vs benchmark, weighted relative performance):
- 100 points: Relative outperformance > +50% (top 1%)
- 95 points: +30% to +50%
- 90 points: +20% to +30%
- 80 points: +10% to +20%
- 70 points: +5% to +10%
- 60 points: 0% to +5%
- 50 points: -5% to 0%
- 40 points: -10% to -5%
- 20 points: -20% to -10%
- 0 points: < -20%
When no benchmark is available, scoring falls back to the weighted absolute performance
of the stock and a 20% penalty is applied (preserves the legacy fallback behavior).
"""
from typing import Optional
# Multi-period configuration (trading days)
DEFAULT_PERIODS_DAYS = {"3m": 63, "6m": 126, "12m": 252}
DEFAULT_PERIOD_WEIGHTS = {"3m": 0.40, "6m": 0.30, "12m": 0.30}
def _sort_prices_ascending(prices: list[dict]) -> list[dict]:
"""
Normalize a price list to ascending date order.
FMP returns historical prices newest-first; some callers (notably the legacy
leadership_calculator) accept either ordering. Normalizing here lets the
multi-period helpers always work with the most recent close at index -1.
"""
if not prices:
return []
# Use string sort on ISO-format dates ("YYYY-MM-DD" sorts correctly lexicographically).
# Entries without a "date" key keep their original relative order.
try:
return sorted(prices, key=lambda p: p.get("date", ""))
except TypeError:
return list(prices)
def slice_period_return(prices: list[dict], days: int) -> Optional[float]:
"""
Compute the percentage return over the most recent ``days`` trading bars.
The input list is normalized to ascending date order internally, so the function
is agnostic to whether FMP returned newest-first or oldest-first.
Returns None when there are fewer than ``days`` bars available, or when the
starting close is not strictly positive.
"""
if not prices or days <= 0:
return None
sorted_prices = _sort_prices_ascending(prices)
if len(sorted_prices) < days:
return None
window = sorted_prices[-days:]
try:
start_price = window[0].get("close", 0)
end_price = window[-1].get("close", 0)
except (AttributeError, KeyError, TypeError):
return None
if not isinstance(start_price, (int, float)) or not isinstance(end_price, (int, float)):
return None
if start_price <= 0:
return None
return ((end_price - start_price) / start_price) * 100
def compute_multi_period_rs(
stock_prices: list[dict],
benchmark_prices: Optional[list[dict]],
periods_days: Optional[dict] = None,
weights: Optional[dict] = None,
) -> dict:
"""
Compute multi-period Relative Strength against an optional benchmark.
Args:
stock_prices: List of {"date": str, "close": float, ...} for the stock.
benchmark_prices: Same shape for the benchmark, or None (no benchmark).
periods_days: Mapping of period label ("3m"/"6m"/"12m") to bar count.
Defaults to DEFAULT_PERIODS_DAYS.
weights: Mapping of period label to weight in [0, 1]. Defaults to
DEFAULT_PERIOD_WEIGHTS. Available periods are re-normalized
so their weights sum to 1.0.
Returns a dict with:
rs_3m_return / rs_6m_return / rs_12m_return: stock absolute return %
benchmark_3m_return / benchmark_6m_return / benchmark_12m_return: benchmark return %
rel_3m / rel_6m / rel_12m: stock_return - benchmark_return (None when no benchmark)
weighted_stock_performance: re-normalized weighted absolute return (None if all missing)
weighted_relative_performance: re-normalized weighted relative return
(None when no benchmark or all missing)
available_periods: list of labels with valid stock returns
missing_periods: list of labels missing valid stock returns
"""
periods_days = periods_days or DEFAULT_PERIODS_DAYS
weights = weights or DEFAULT_PERIOD_WEIGHTS
stock_returns = {}
benchmark_returns = {}
relative_returns = {}
for label, days in periods_days.items():
stock_returns[label] = slice_period_return(stock_prices, days)
if benchmark_prices is not None:
benchmark_returns[label] = slice_period_return(benchmark_prices, days)
else:
benchmark_returns[label] = None
if stock_returns[label] is not None and benchmark_returns[label] is not None:
relative_returns[label] = stock_returns[label] - benchmark_returns[label]
else:
relative_returns[label] = None
available = [label for label, value in stock_returns.items() if value is not None]
missing = [label for label in periods_days if label not in available]
weighted_stock = _weighted_average(stock_returns, weights, available)
# Weighted relative performance only makes sense when ALL available periods
# also have a benchmark return; otherwise it would mix relative vs. absolute terms.
rel_available = [label for label in available if relative_returns.get(label) is not None]
weighted_relative = _weighted_average(relative_returns, weights, rel_available)
return {
"rs_3m_return": _round_or_none(stock_returns.get("3m")),
"rs_6m_return": _round_or_none(stock_returns.get("6m")),
"rs_12m_return": _round_or_none(stock_returns.get("12m")),
"benchmark_3m_return": _round_or_none(benchmark_returns.get("3m")),
"benchmark_6m_return": _round_or_none(benchmark_returns.get("6m")),
"benchmark_12m_return": _round_or_none(benchmark_returns.get("12m")),
"rel_3m": _round_or_none(relative_returns.get("3m")),
"rel_6m": _round_or_none(relative_returns.get("6m")),
"rel_12m": _round_or_none(relative_returns.get("12m")),
"weighted_stock_performance": _round_or_none(weighted_stock),
"weighted_relative_performance": _round_or_none(weighted_relative),
"available_periods": available,
"missing_periods": missing,
}
def _weighted_average(values: dict, weights: dict, available_keys: list[str]) -> Optional[float]:
"""
Compute weighted average over the available keys, re-normalizing the weights
so they sum to 1.0. Returns None if no keys are available or all weights are zero.
"""
if not available_keys:
return None
weight_sum = sum(weights.get(k, 0.0) for k in available_keys)
if weight_sum <= 0:
return None
total = 0.0
for k in available_keys:
v = values.get(k)
if v is None:
return None
total += v * (weights.get(k, 0.0) / weight_sum)
return total
def _round_or_none(value: Optional[float], ndigits: int = 2) -> Optional[float]:
"""Round to ``ndigits`` decimals when value is numeric, otherwise return None."""
if value is None:
return None
if not isinstance(value, (int, float)):
return None
return round(value, ndigits)
def classify_rs_rating(rs_rank_estimate: Optional[int]) -> str:
"""
Convert an RS rank estimate (0-99) into a short label.
Uses fixed thresholds so it is independent of any human-readable interpretation
string. Returns "Weak" when the rank is None.
"""
if rs_rank_estimate is None:
return "Weak"
if rs_rank_estimate >= 90:
return "Market Leader"
if rs_rank_estimate >= 80:
return "Strong"
if rs_rank_estimate >= 60:
return "Above Average"
if rs_rank_estimate >= 40:
return "Average"
if rs_rank_estimate >= 25:
return "Laggard"
return "Weak"
def calculate_leadership(
historical_prices: list[dict],
sp500_historical: Optional[list[dict]] = None,
sp500_performance: Optional[float] = None,
rs_benchmark: str = "^GSPC",
) -> dict:
"""
Calculate Leadership/Relative Strength score (L component).
Args:
historical_prices: List of daily price data for the stock (any order).
Each entry: {"date": str, "close": float, "volume": int}
sp500_historical: Optional list of daily benchmark prices (same shape).
Used by the modern multi-period path.
sp500_performance: Optional pre-calculated benchmark return (legacy path).
When provided, multi-period calculation is skipped.
rs_benchmark: Symbol of the benchmark used (default: "^GSPC"). Surfaced in
the returned dict; does not affect calculation since the data
is supplied via sp500_historical / sp500_performance.
Returns a dict containing both legacy and Phase 3.1 fields. See module docstring
for full schema details.
"""
# Validate input
if not historical_prices or len(historical_prices) < 50:
return _empty_result(
error="Insufficient historical price data (need 50+ days)",
interpretation="Data unavailable",
rs_benchmark=rs_benchmark,
)
# Compute legacy 365-day full-window stock performance for backward compatibility.
# This preserves the existing semantics of stock_52w_performance.
try:
sorted_stock = _sort_prices_ascending(historical_prices)
start_price = sorted_stock[0].get("close", 0)
end_price = sorted_stock[-1].get("close", 0)
if start_price <= 0:
return _empty_result(
error="Invalid start price (zero or negative)",
interpretation="Data quality issue",
rs_benchmark=rs_benchmark,
)
legacy_stock_performance = ((end_price - start_price) / start_price) * 100
days_analyzed = len(sorted_stock)
except (KeyError, TypeError, ZeroDivisionError) as e:
return _empty_result(
error=f"Price calculation error: {e}",
interpretation="Calculation error",
rs_benchmark=rs_benchmark,
)
# ---- Legacy path: pre-calculated benchmark performance ----
if sp500_performance is not None:
return _legacy_single_period_path(
stock_performance=legacy_stock_performance,
sp500_performance=sp500_performance,
days_analyzed=days_analyzed,
rs_benchmark=rs_benchmark,
)
# ---- Modern path: multi-period weighted RS ----
multi = compute_multi_period_rs(historical_prices, sp500_historical)
# Legacy 365-day relative performance (kept for backward compatibility).
legacy_benchmark_performance = None
legacy_relative_performance = None
quality_warning = None
if sp500_historical and len(sp500_historical) >= 50:
try:
sorted_bench = _sort_prices_ascending(sp500_historical)
bench_start = sorted_bench[0].get("close", 0)
bench_end = sorted_bench[-1].get("close", 0)
if bench_start > 0:
legacy_benchmark_performance = ((bench_end - bench_start) / bench_start) * 100
legacy_relative_performance = (
legacy_stock_performance - legacy_benchmark_performance
)
except (KeyError, TypeError, ZeroDivisionError):
quality_warning = "Benchmark performance calculation failed"
elif sp500_historical is None:
quality_warning = (
f"Benchmark ({rs_benchmark}) data unavailable - using absolute performance only"
)
else:
quality_warning = (
f"Benchmark ({rs_benchmark}) data insufficient - using absolute performance only"
)
# 4-step fallback hierarchy (preferring relative-vs-benchmark over absolute):
# 1. multi-period weighted relative (full Phase 3.1 path)
# 2. multi-period weighted absolute (multi-period available but no benchmark)
# 3. legacy 365-day relative (multi-period missing but benchmark + >=50 bars)
# 4. legacy 365-day absolute (no benchmark and no multi-period)
if multi["weighted_relative_performance"] is not None:
scoring_input = multi["weighted_relative_performance"]
has_benchmark = True
elif multi["weighted_stock_performance"] is not None:
scoring_input = multi["weighted_stock_performance"]
has_benchmark = False
elif legacy_relative_performance is not None:
scoring_input = legacy_relative_performance
has_benchmark = True
else:
scoring_input = legacy_stock_performance
has_benchmark = False
# If even the fallback stock performance is unavailable, flag as data error.
if scoring_input is None:
return _empty_result(
error="Unable to compute relative strength (no usable price periods)",
interpretation="Data unavailable",
rs_benchmark=rs_benchmark,
)
score, rs_rank_estimate = score_leadership(scoring_input, has_benchmark)
rs_rating = classify_rs_rating(rs_rank_estimate)
interpretation = interpret_leadership(
score=score,
stock_performance=legacy_stock_performance,
sp500_performance=legacy_benchmark_performance,
relative_performance=(
legacy_relative_performance
if legacy_relative_performance is not None
else legacy_stock_performance
),
days_analyzed=days_analyzed,
)
return {
# ---- Legacy fields (semantics preserved) ----
"score": score,
"stock_52w_performance": round(legacy_stock_performance, 2),
"sp500_52w_performance": (
round(legacy_benchmark_performance, 2)
if legacy_benchmark_performance is not None
else None
),
"relative_performance": (
round(legacy_relative_performance, 2)
if legacy_relative_performance is not None
else round(legacy_stock_performance, 2)
),
"rs_rank_estimate": rs_rank_estimate,
"days_analyzed": days_analyzed,
"interpretation": interpretation,
"quality_warning": quality_warning,
"error": None,
# ---- Phase 3.1 multi-period fields ----
"rs_3m_return": multi["rs_3m_return"],
"rs_6m_return": multi["rs_6m_return"],
"rs_12m_return": multi["rs_12m_return"],
"benchmark_3m_return": multi["benchmark_3m_return"],
"benchmark_6m_return": multi["benchmark_6m_return"],
"benchmark_12m_return": multi["benchmark_12m_return"],
"rel_3m": multi["rel_3m"],
"rel_6m": multi["rel_6m"],
"rel_12m": multi["rel_12m"],
"weighted_stock_performance": multi["weighted_stock_performance"],
"weighted_relative_performance": multi["weighted_relative_performance"],
"available_periods": multi["available_periods"],
"missing_periods": multi["missing_periods"],
"benchmark_52w_performance": (
round(legacy_benchmark_performance, 2)
if legacy_benchmark_performance is not None
else None
),
"rs_benchmark": rs_benchmark,
"rs_benchmark_relative_return": multi["rel_12m"],
"rs_rating": rs_rating,
"rs_component_score": score,
"rs_rank_percentile": rs_rank_estimate,
}
def _empty_result(error: str, interpretation: str, rs_benchmark: str = "^GSPC") -> dict:
"""Return a fully-populated zero-score result for error paths."""
return {
"score": 0,
"error": error,
"stock_52w_performance": None,
"sp500_52w_performance": None,
"relative_performance": None,
"rs_rank_estimate": None,
"days_analyzed": 0,
"interpretation": interpretation,
"quality_warning": None,
"rs_3m_return": None,
"rs_6m_return": None,
"rs_12m_return": None,
"benchmark_3m_return": None,
"benchmark_6m_return": None,
"benchmark_12m_return": None,
"rel_3m": None,
"rel_6m": None,
"rel_12m": None,
"weighted_stock_performance": None,
"weighted_relative_performance": None,
"available_periods": [],
"missing_periods": list(DEFAULT_PERIODS_DAYS.keys()),
"benchmark_52w_performance": None,
"rs_benchmark": rs_benchmark,
"rs_benchmark_relative_return": None,
"rs_rating": "Weak",
"rs_component_score": 0,
"rs_rank_percentile": None,
}
def _legacy_single_period_path(
stock_performance: float,
sp500_performance: float,
days_analyzed: int,
rs_benchmark: str,
) -> dict:
"""
Legacy scoring path used when a pre-calculated benchmark return is supplied.
Multi-period fields are returned as None.
"""
relative_performance = stock_performance - sp500_performance
score, rs_rank_estimate = score_leadership(relative_performance, has_benchmark=True)
rs_rating = classify_rs_rating(rs_rank_estimate)
interpretation = interpret_leadership(
score=score,
stock_performance=stock_performance,
sp500_performance=sp500_performance,
relative_performance=relative_performance,
days_analyzed=days_analyzed,
)
return {
"score": score,
"stock_52w_performance": round(stock_performance, 2),
"sp500_52w_performance": round(sp500_performance, 2),
"relative_performance": round(relative_performance, 2),
"rs_rank_estimate": rs_rank_estimate,
"days_analyzed": days_analyzed,
"interpretation": interpretation,
"quality_warning": None,
"error": None,
"rs_3m_return": None,
"rs_6m_return": None,
"rs_12m_return": None,
"benchmark_3m_return": None,
"benchmark_6m_return": None,
"benchmark_12m_return": None,
"rel_3m": None,
"rel_6m": None,
"rel_12m": None,
"weighted_stock_performance": None,
"weighted_relative_performance": None,
"available_periods": [],
"missing_periods": list(DEFAULT_PERIODS_DAYS.keys()),
"benchmark_52w_performance": round(sp500_performance, 2),
"rs_benchmark": rs_benchmark,
"rs_benchmark_relative_return": None,
"rs_rating": rs_rating,
"rs_component_score": score,
"rs_rank_percentile": rs_rank_estimate,
}
def score_leadership(relative_performance: float, has_benchmark: bool) -> tuple:
"""
Score leadership based on relative performance.
Args:
relative_performance: Stock minus benchmark performance (%) when has_benchmark=True,
otherwise the stock's absolute performance (%) is used as
a fallback metric.
has_benchmark: True if a benchmark was available; False applies a 20% penalty.
Returns:
tuple: (score, rs_rank_estimate)
"""
if relative_performance >= 50:
score = 100
rs_rank_estimate = 99
elif relative_performance >= 30:
score = 95
rs_rank_estimate = 95
elif relative_performance >= 20:
score = 90
rs_rank_estimate = 90
elif relative_performance >= 10:
score = 80
rs_rank_estimate = 80
elif relative_performance >= 5:
score = 70
rs_rank_estimate = 70
elif relative_performance >= 0:
score = 60
rs_rank_estimate = 60
elif relative_performance >= -5:
score = 50
rs_rank_estimate = 50
elif relative_performance >= -10:
score = 40
rs_rank_estimate = 40
elif relative_performance >= -20:
score = 20
rs_rank_estimate = 25
else:
score = 0
rs_rank_estimate = 10
if not has_benchmark:
score = int(score * 0.8)
rs_rank_estimate = int(rs_rank_estimate * 0.9)
return score, rs_rank_estimate
def interpret_leadership(
score: int,
stock_performance: float,
sp500_performance: Optional[float],
relative_performance: float,
days_analyzed: int,
) -> str:
"""Generate a human-readable interpretation string."""
if days_analyzed >= 250:
period = "52-week"
elif days_analyzed >= 180:
period = "9-month"
elif days_analyzed >= 90:
period = "quarterly"
else:
period = f"{days_analyzed}-day"
if stock_performance > 0:
stock_msg = f"+{stock_performance:.1f}%"
else:
stock_msg = f"{stock_performance:.1f}%"
if sp500_performance is not None:
if relative_performance > 0:
rel_msg = f"+{relative_performance:.1f}% vs benchmark"
else:
rel_msg = f"{relative_performance:.1f}% vs benchmark"
else:
rel_msg = "(absolute performance)"
if score >= 90:
rating = "Market Leader"
action = "Strong momentum, prime CANSLIM candidate"
elif score >= 80:
rating = "Strong Performer"
action = "Outperforming market significantly"
elif score >= 60:
rating = "Above Average"
action = "Slight outperformance"
elif score >= 40:
rating = "Average"
action = "Matching or slightly lagging market"
elif score >= 20:
rating = "Laggard"
action = "Underperforming market - caution"
else:
rating = "Weak"
action = "Significant underperformance - avoid"
return f"{rating} - {period} return {stock_msg} ({rel_msg}). {action}"
def calculate_sector_relative_strength(
stock_performance: float, sector_stocks_performance: list[float]
) -> dict:
"""
Calculate relative strength within sector (optional enhancement).
Args:
stock_performance: Target stock's period return (%)
sector_stocks_performance: List of sector peers' period returns (%)
Returns:
Dict with sector_rank, sector_percentile, is_sector_leader.
"""
if not sector_stocks_performance:
return {
"sector_rank": None,
"sector_percentile": None,
"is_sector_leader": False,
"error": "No sector data available",
}
all_stocks = sector_stocks_performance + [stock_performance]
all_stocks_sorted = sorted(all_stocks, reverse=True)
rank = all_stocks_sorted.index(stock_performance) + 1
total = len(all_stocks)
percentile = ((total - rank) / total) * 100
return {
"sector_rank": rank,
"sector_total": total,
"sector_percentile": round(percentile, 1),
"is_sector_leader": percentile >= 80,
}
# Example usage
if __name__ == "__main__":
print("Testing Leadership Calculator (L Component)...")
print()
sample_prices = [
{"date": "2024-01-01", "close": 100.0},
{"date": "2024-06-01", "close": 120.0},
{"date": "2025-01-01", "close": 180.0},
]
sample_sp500 = [
{"date": "2024-01-01", "close": 4500.0},
{"date": "2024-06-01", "close": 4700.0},
{"date": "2025-01-01", "close": 5400.0},
]
result = calculate_leadership(sample_prices, sample_sp500)
print("Test 1: Strong Outperformer")
print(f" Score: {result['score']}/100")
print(f" Interpretation: {result['interpretation']}")
print()
result3 = calculate_leadership(sample_prices)
print("Test 3: Without S&P 500 Data (Fallback)")
print(f" Score: {result3['score']}/100 (penalty applied)")
print(f" Warning: {result3['quality_warning']}")
print()
print("Done.")
scripts/calculators/market_calculator.py
#!/usr/bin/env python3
"""
M Component - Market Direction Calculator
Calculates CANSLIM 'M' component score based on overall market trend.
O'Neil's Rule: "You can be right about a stock but wrong about the market,
and still lose money. Three out of four stocks follow the market's trend."
Scoring:
- 100: Strong uptrend (S&P 500 >2% above 50-EMA) + VIX <15
- 80: Uptrend (S&P 500 above 50-EMA) + VIX <20
- 60: Early uptrend (S&P 500 just above 50-EMA)
- 40: Choppy/neutral (S&P 500 near 50-EMA ±2%)
- 20: Downtrend forming (S&P 500 below 50-EMA)
- 0: Bear market (S&P 500 well below 50-EMA OR VIX >30) - DO NOT BUY
"""
from typing import Optional
def calculate_market_direction(
sp500_quote: dict, sp500_prices: Optional[list[dict]] = None, vix_quote: Optional[dict] = None
) -> dict:
"""
Calculate M component score based on S&P 500 trend and VIX
Args:
sp500_quote: S&P 500 quote data (symbol ^GSPC)
sp500_prices: Optional historical prices for EMA calculation
vix_quote: Optional VIX quote (symbol ^VIX)
Returns:
Dict with:
- score: 0-100 points
- trend: "strong_uptrend", "uptrend", "choppy", "downtrend", "bear_market"
- sp500_price: Current S&P 500 level
- sp500_ema_50: 50-day EMA (if calculated)
- distance_from_ema_pct: Distance from 50-EMA (%)
- vix_level: Current VIX level
- interpretation: Human-readable interpretation
- warning: Warning message if bear market (M=0)
"""
# Validate input
if not sp500_quote:
return {
"score": 50, # Default to neutral if data unavailable
"error": "S&P 500 quote data missing",
"trend": "unknown",
"interpretation": "Market data unavailable",
}
sp500_price = sp500_quote.get("price")
if not sp500_price:
return {
"score": 50,
"error": "S&P 500 price missing",
"trend": "unknown",
"interpretation": "Market data unavailable",
}
# Calculate 50-day EMA (requires real historical bars)
sp500_ema_50 = None
if sp500_prices and len(sp500_prices) >= 50:
# Calculate from historical data
close_prices = [day.get("close") for day in sp500_prices if day.get("close")]
if len(close_prices) >= 50:
sp500_ema_50 = calculate_ema(close_prices, period=50)
if not sp500_ema_50:
# Do not fabricate a trend when history is missing. Fabricating a fixed
# -2% EMA offset would always trip the 'uptrend'/'strong_uptrend' branch,
# inverting the safety purpose of the M component during a data outage.
return {
"score": 50, # Neutral when trend cannot be determined
"error": "Insufficient S&P 500 history for 50-day EMA",
"trend": "unknown",
"sp500_price": sp500_price,
"sp500_ema_50": None,
"interpretation": "Market trend unavailable - insufficient historical data",
}
# Calculate distance from EMA
distance_from_ema_pct = ((sp500_price / sp500_ema_50) - 1) * 100 if sp500_ema_50 else 0
# Get VIX level
vix_level = None
if vix_quote:
vix_level = vix_quote.get("price")
# Determine trend
if distance_from_ema_pct >= 2.0:
trend = "strong_uptrend"
elif distance_from_ema_pct >= 0:
trend = "uptrend"
elif distance_from_ema_pct >= -2.0:
trend = "choppy"
elif distance_from_ema_pct >= -5.0:
trend = "downtrend"
else:
trend = "bear_market"
# Calculate score
score = score_market_direction(trend, vix_level)
# Generate interpretation
interpretation = interpret_market_score(score, trend, distance_from_ema_pct, vix_level)
# Warning for bear market
warning = None
if score == 0:
warning = (
"⚠️ BEAR MARKET - Do not buy stocks regardless of C, A, N scores. "
"Raise 80-100% cash and wait for market recovery."
)
return {
"score": score,
"trend": trend,
"sp500_price": sp500_price,
"sp500_ema_50": sp500_ema_50,
"distance_from_ema_pct": round(distance_from_ema_pct, 2),
"vix_level": vix_level,
"interpretation": interpretation,
"warning": warning,
"error": None,
}
def calculate_ema(prices: list[float], period: int = 50) -> float:
"""
Calculate Exponential Moving Average
Args:
prices: List of prices (most recent first)
period: EMA period (default 50)
Returns:
EMA value
Formula: EMA = Price * k + EMA_prev * (1-k), where k = 2/(period+1)
"""
if len(prices) < period:
return sum(prices) / len(prices) # Fallback to simple average
# Reverse to oldest-first for calculation
prices_reversed = prices[::-1]
# Initialize with SMA
sma = sum(prices_reversed[:period]) / period
ema = sma
# Calculate EMA
k = 2 / (period + 1)
for price in prices_reversed[period:]:
ema = price * k + ema * (1 - k)
return ema
def score_market_direction(trend: str, vix_level: Optional[float]) -> int:
"""
Score M component based on trend and VIX
Args:
trend: Market trend classification
vix_level: Current VIX level
Returns:
Score (0-100)
"""
# Base score from trend
if trend == "strong_uptrend":
base_score = 90
elif trend == "uptrend":
base_score = 70
elif trend == "choppy":
base_score = 40
elif trend == "downtrend":
base_score = 20
else: # bear_market
base_score = 0
# VIX adjustments
if vix_level:
if vix_level < 15:
base_score += 10 # Low fear, bullish
elif vix_level > 30:
base_score = 0 # Panic mode - override trend
return min(max(base_score, 0), 100)
def interpret_market_score(score: int, trend: str, distance: float, vix: Optional[float]) -> str:
"""
Generate human-readable market interpretation
Args:
score: M component score
trend: Trend classification
distance: Distance from 50-EMA (%)
vix: VIX level
Returns:
Interpretation string
"""
vix_text = f", VIX {vix:.1f}" if vix else ""
if score >= 90:
return f"Strong bull market - Aggressive buying recommended (S&P 500 {distance:+.1f}% from 50-EMA{vix_text})"
elif score >= 70:
return f"Bull market - Standard position sizing (S&P 500 {distance:+.1f}% from 50-EMA{vix_text})"
elif score >= 50:
return f"Early uptrend - Small initial positions (S&P 500 {distance:+.1f}% from 50-EMA{vix_text})"
elif score >= 30:
return f"Choppy/neutral - Reduce exposure, be selective (S&P 500 {distance:+.1f}% from 50-EMA{vix_text})"
elif score >= 10:
return f"Downtrend forming - Defensive posture (S&P 500 {distance:+.1f}% from 50-EMA{vix_text})"
else:
return f"Bear market - Raise 80-100% cash, DO NOT BUY (S&P 500 {distance:+.1f}% from 50-EMA{vix_text})"
# Example usage
if __name__ == "__main__":
print("Testing Market Calculator (M Component)...\n")
# Test 1: Strong uptrend (score 100)
test_sp500_1 = {"symbol": "^GSPC", "price": 4900}
test_vix_1 = {"symbol": "^VIX", "price": 12.5}
result1 = calculate_market_direction(test_sp500_1, vix_quote=test_vix_1)
print(f"Test 1: Strong Uptrend - Score: {result1['score']}")
print(f" {result1['interpretation']}\n")
# Test 2: Choppy market (score 40)
test_sp500_2 = {"symbol": "^GSPC", "price": 4700}
# EMA estimated at 4750 (price 1% below)
test_vix_2 = {"symbol": "^VIX", "price": 18.0}
result2 = calculate_market_direction(test_sp500_2, vix_quote=test_vix_2)
print(f"Test 2: Choppy Market - Score: {result2['score']}")
print(f" {result2['interpretation']}\n")
# Test 3: Bear market (score 0)
test_sp500_3 = {"symbol": "^GSPC", "price": 4200}
# EMA estimated at 4600 (price 8.7% below)
test_vix_3 = {"symbol": "^VIX", "price": 32.0}
result3 = calculate_market_direction(test_sp500_3, vix_quote=test_vix_3)
print(f"Test 3: Bear Market - Score: {result3['score']}")
print(f" {result3['interpretation']}")
if result3["warning"]:
print(f" {result3['warning']}\n")
print("✓ All tests completed")
scripts/calculators/new_highs_calculator.py
#!/usr/bin/env python3
"""
N Component - Newness / New Highs Calculator
Calculates CANSLIM 'N' component score based on price position relative to
52-week high and momentum indicators.
O'Neil's Rule: "Stocks making new price highs have no overhead supply.
New products, services, or management catalyze major moves."
Scoring:
- 100: Within 5% of 52-week high + breakout + new product catalyst
- 80: Within 10% of 52-week high + breakout
- 60: Within 15% of 52-week high OR breakout
- 40: Within 25% of 52-week high
- 20: >25% from 52-week high (lacks momentum)
"""
from typing import Optional
def calculate_newness(quote: dict, historical_prices: Optional[dict] = None) -> dict:
"""
Calculate N component score based on price position and momentum
Args:
quote: Stock quote data from FMP API (contains yearHigh, price, volume)
historical_prices: Optional historical price data for detailed analysis
Returns:
Dict with:
- score: 0-100 points
- distance_from_high_pct: Distance from 52-week high (%)
- current_price: Current stock price
- week_52_high: 52-week high price
- week_52_low: 52-week low price
- breakout_detected: Boolean
- interpretation: Human-readable interpretation
"""
# Validate input
if not quote:
return {
"score": 0,
"error": "Quote data missing",
"distance_from_high_pct": None,
"interpretation": "Data unavailable",
}
# Extract price data
current_price = quote.get("price")
week_52_high = quote.get("yearHigh")
week_52_low = quote.get("yearLow")
current_volume = quote.get("volume")
avg_volume = quote.get("avgVolume")
if not current_price or not week_52_high:
return {
"score": 0,
"error": "Price or 52-week high data missing",
"distance_from_high_pct": None,
"interpretation": "Data unavailable",
}
# Calculate distance from 52-week high
distance_from_high_pct = ((current_price / week_52_high) - 1) * 100
# Detect breakout (new high on elevated volume)
breakout_detected = False
if current_volume and avg_volume:
# Within 0.5% of high AND volume 40%+ above average
if current_price >= week_52_high * 0.995 and current_volume > avg_volume * 1.4:
breakout_detected = True
# Calculate score
score = score_newness(distance_from_high_pct, breakout_detected)
# Generate interpretation
interpretation = interpret_newness_score(score, distance_from_high_pct, breakout_detected)
return {
"score": score,
"distance_from_high_pct": round(distance_from_high_pct, 1),
"current_price": current_price,
"week_52_high": week_52_high,
"week_52_low": week_52_low,
"breakout_detected": breakout_detected,
"current_volume": current_volume,
"avg_volume": avg_volume,
"volume_ratio": round(current_volume / avg_volume, 2) if avg_volume else None,
"interpretation": interpretation,
"error": None,
}
def score_newness(distance_from_high_pct: float, breakout_detected: bool) -> int:
"""
Score N component based on price position and breakout
Args:
distance_from_high_pct: Distance from 52-week high (negative = below high)
breakout_detected: Boolean indicating volume-confirmed breakout
Returns:
Score (0-100)
Scoring Logic:
- Within 5% of high + breakout: 100
- Within 10% of high + breakout: 80
- Within 15% of high OR breakout: 60
- Within 25% of high: 40
- >25% from high: 20
"""
if distance_from_high_pct >= -5 and breakout_detected:
return 100 # Perfect setup - at new highs with volume
elif distance_from_high_pct >= -10 and breakout_detected:
return 80 # Strong momentum
elif distance_from_high_pct >= -15 or breakout_detected:
return 60 # Acceptable
elif distance_from_high_pct >= -25:
return 40 # Weak momentum
else:
return 20 # Too far from highs, lacks sponsorship
def interpret_newness_score(score: int, distance: float, breakout: bool) -> str:
"""
Generate human-readable interpretation
Args:
score: Component score
distance: Distance from 52-week high (%)
breakout: Breakout detected flag
Returns:
Interpretation string
"""
breakout_text = " with volume breakout" if breakout else ""
if score >= 90:
return f"Exceptional - At new highs{breakout_text} ({distance:+.1f}% from high)"
elif score >= 70:
return f"Strong - Near 52-week high{breakout_text} ({distance:+.1f}% from high)"
elif score >= 50:
return f"Acceptable - Within 15% of high ({distance:+.1f}% from high)"
elif score >= 30:
return f"Weak - Lacks momentum ({distance:+.1f}% from high)"
else:
return f"Poor - Too far from highs, overhead resistance ({distance:+.1f}% from high)"
# Example usage
if __name__ == "__main__":
print("Testing New Highs Calculator (N Component)...\n")
# Test 1: At new high with breakout (score 100)
test_quote_1 = {
"symbol": "NVDA",
"price": 495.50,
"yearHigh": 496.00,
"yearLow": 280.00,
"volume": 60000000,
"avgVolume": 40000000,
}
result1 = calculate_newness(test_quote_1)
print(f"Test 1: New High + Breakout - Score: {result1['score']}")
print(f" {result1['interpretation']}\n")
# Test 2: Within 10% of high, no breakout (score 60)
test_quote_2 = {
"symbol": "META",
"price": 450.00,
"yearHigh": 490.00,
"yearLow": 320.00,
"volume": 15000000,
"avgVolume": 16000000,
}
result2 = calculate_newness(test_quote_2)
print(f"Test 2: Near High, No Breakout - Score: {result2['score']}")
print(f" {result2['interpretation']}\n")
# Test 3: Far from high (score 20)
test_quote_3 = {
"symbol": "XYZ",
"price": 50.00,
"yearHigh": 80.00,
"yearLow": 45.00,
"volume": 1000000,
"avgVolume": 1200000,
}
result3 = calculate_newness(test_quote_3)
print(f"Test 3: Far from High - Score: {result3['score']}")
print(f" {result3['interpretation']}\n")
print("✓ All tests completed")
scripts/calculators/supply_demand_calculator.py
#!/usr/bin/env python3
"""
S Component - Supply and Demand Calculator
Calculates CANSLIM 'S' component score based on volume accumulation/distribution patterns.
O'Neil's Rule: "Volume is the gas in the tank of a stock. Without gas, the car doesn't
go anywhere. Look for stocks where volume expands on up days and contracts on down days."
Key Principle:
- UP-DAY VOLUME > DOWN-DAY VOLUME = Accumulation (institutions buying)
- DOWN-DAY VOLUME > UP-DAY VOLUME = Distribution (institutions selling)
Scoring:
- 100 points: Up/down ratio ≥ 2.0 (Strong Accumulation)
- 80 points: Ratio 1.5-2.0 (Accumulation)
- 60 points: Ratio 1.0-1.5 (Neutral/Weak Accumulation)
- 40 points: Ratio 0.7-1.0 (Neutral/Weak Distribution)
- 20 points: Ratio 0.5-0.7 (Distribution)
- 0 points: Ratio < 0.5 (Strong Distribution)
"""
def calculate_supply_demand(historical_prices: dict) -> dict:
"""
Calculate supply/demand dynamics based on volume patterns
Args:
historical_prices: Historical price data from FMP API
Dict with keys: 'historical' (list of daily price/volume data)
Each entry should have: date, close, volume
Returns:
Dict with:
- score: 0-100 points
- up_down_ratio: Average up-day volume / average down-day volume
- accumulation_detected: True if ratio ≥ 1.5
- avg_up_volume: Average volume on up days
- avg_down_volume: Average volume on down days
- up_days_count: Number of up days in analysis period
- down_days_count: Number of down days
- interpretation: Human-readable interpretation
- quality_warning: Warning if data quality issues
- error: Error message if calculation failed
Example:
>>> prices = client.get_historical_prices("NVDA", days=90)
>>> result = calculate_supply_demand(prices)
>>> print(f"S Score: {result['score']}, Ratio: {result['up_down_ratio']:.2f}")
"""
# Validate input
if not historical_prices or "historical" not in historical_prices:
return {
"score": 0,
"error": "No historical price data provided",
"up_down_ratio": None,
"accumulation_detected": False,
"avg_up_volume": None,
"avg_down_volume": None,
"up_days_count": 0,
"down_days_count": 0,
"interpretation": "Data unavailable",
}
prices = historical_prices["historical"]
if not prices or len(prices) < 60:
return {
"score": 0,
"error": f"Insufficient data (need 60+ days, got {len(prices)})",
"up_down_ratio": None,
"accumulation_detected": False,
"avg_up_volume": None,
"avg_down_volume": None,
"up_days_count": 0,
"down_days_count": 0,
"interpretation": "Data unavailable",
"quality_warning": "Less than 60 days of historical data available",
}
# Analyze last 60 days (prices are typically newest first, so take first 60)
analysis_period = prices[:60]
# Classify days as up or down
up_days = []
down_days = []
previous_close = None
for day in reversed(analysis_period): # Process chronologically
current_close = day.get("close")
current_volume = day.get("volume")
if current_close is None or current_volume is None:
continue # Skip incomplete data
if previous_close is not None:
if current_close > previous_close:
up_days.append(current_volume)
elif current_close < previous_close:
down_days.append(current_volume)
# Unchanged days are ignored
previous_close = current_close
# Validate we have enough classified days
if len(up_days) < 10 or len(down_days) < 10:
return {
"score": 0,
"error": "Insufficient up/down days for analysis",
"up_down_ratio": None,
"accumulation_detected": False,
"avg_up_volume": None,
"avg_down_volume": None,
"up_days_count": len(up_days),
"down_days_count": len(down_days),
"interpretation": "Data quality issues",
"quality_warning": f"Too few classified days (up: {len(up_days)}, down: {len(down_days)})",
}
# Calculate average volumes
avg_up_volume = sum(up_days) / len(up_days)
avg_down_volume = sum(down_days) / len(down_days)
# Calculate accumulation/distribution ratio
if avg_down_volume == 0:
# Edge case: no volume on down days (extremely bullish, but rare)
ratio = 5.0 # Cap at 5.0 to avoid infinity
else:
ratio = avg_up_volume / avg_down_volume
# Determine accumulation status
accumulation_detected = ratio >= 1.5
# Score based on ratio
score = score_supply_demand(ratio)
# Generate interpretation
interpretation = interpret_supply_demand(ratio, accumulation_detected)
return {
"score": score,
"up_down_ratio": ratio,
"accumulation_detected": accumulation_detected,
"avg_up_volume": int(avg_up_volume),
"avg_down_volume": int(avg_down_volume),
"up_days_count": len(up_days),
"down_days_count": len(down_days),
"interpretation": interpretation,
}
def score_supply_demand(ratio: float) -> int:
"""
Score accumulation/distribution ratio
Args:
ratio: Up-day volume / down-day volume
Returns:
int: Score from 0-100
"""
if ratio >= 2.0:
return 100 # Strong Accumulation
elif ratio >= 1.5:
return 80 # Accumulation
elif ratio >= 1.0:
return 60 # Neutral/Weak Accumulation
elif ratio >= 0.7:
return 40 # Neutral/Weak Distribution
elif ratio >= 0.5:
return 20 # Distribution
else:
return 0 # Strong Distribution
def interpret_supply_demand(ratio: float, accumulation: bool) -> str:
"""
Generate human-readable interpretation of supply/demand dynamics
Args:
ratio: Up-day volume / down-day volume
accumulation: True if accumulation detected
Returns:
str: Interpretation string
"""
if ratio >= 2.0:
return f"Strong Accumulation (ratio: {ratio:.2f}x) - Institutions aggressively buying. Volume precedes price."
elif ratio >= 1.5:
return f"Accumulation (ratio: {ratio:.2f}x) - Bullish volume pattern. Institutional interest building."
elif ratio >= 1.0:
return f"Neutral/Weak Accumulation (ratio: {ratio:.2f}x) - Slightly positive volume trend."
elif ratio >= 0.7:
return f"Neutral/Weak Distribution (ratio: {ratio:.2f}x) - Slightly negative volume trend. Monitor closely."
elif ratio >= 0.5:
return f"Distribution (ratio: {ratio:.2f}x) - Bearish volume pattern. Institutions may be selling."
else:
return f"Strong Distribution (ratio: {ratio:.2f}x) - Heavy selling pressure. Volume leads price lower."
# Example usage
if __name__ == "__main__":
# Test with sample data
sample_data = {
"historical": [
{"date": "2024-12-10", "close": 150.0, "volume": 80000000}, # up day
{"date": "2024-12-09", "close": 148.0, "volume": 50000000}, # down day
{"date": "2024-12-06", "close": 149.0, "volume": 75000000}, # up day
{"date": "2024-12-05", "close": 147.0, "volume": 45000000}, # down day
]
* 15 # Repeat to get 60 days
}
result = calculate_supply_demand(sample_data)
print("S Component Test Results:")
print(f" Score: {result['score']}/100")
print(f" Up/Down Ratio: {result['up_down_ratio']:.2f}")
print(f" Accumulation: {result['accumulation_detected']}")
print(f" Interpretation: {result['interpretation']}")
scripts/check_institutional_endpoint.py
#!/usr/bin/env python3
"""
Test FMP institutional-holder endpoint availability
Critical decision point for Phase 2 implementation
"""
import os
import sys
from datetime import date
import requests
def _latest_completed_quarter(as_of=None):
"""(year, quarter) of the most recent completed calendar quarter."""
d = as_of or date.today()
year = d.year
quarter = (d.month - 1) // 3 # current quarter (1-4) minus 1 = last completed
if quarter == 0:
quarter, year = 4, year - 1
return year, quarter
def check_institutional_endpoint():
"""
Test if /stable institutional-ownership data is available with the API key.
The skill sources the CANSLIM 'I' component from /stable
institutional-ownership (symbol-positions-summary for the holder count +
ownership %), with a v3 fallback handled automatically in FMPClient. This
is just an availability probe.
Returns:
bool: True if institutional data is reachable, False otherwise.
"""
api_key = os.environ.get("FMP_API_KEY")
if not api_key:
print("ERROR: FMP_API_KEY environment variable not set")
print("Please set it with: export FMP_API_KEY=your_key")
return False
print(f"Testing /stable institutional-ownership with API key (length: {len(api_key)})...")
print()
test_symbol = "AAPL"
year, quarter = _latest_completed_quarter()
url = (
"https://financialmodelingprep.com/stable/institutional-ownership/symbol-positions-summary"
)
params = {"symbol": test_symbol, "year": year, "quarter": quarter, "apikey": api_key}
try:
response = requests.get(url, params=params, timeout=10)
print(f"Status Code: {response.status_code} (year={year} Q{quarter})")
print(f"Response length: {len(response.text)} bytes")
print()
if response.status_code == 200:
data = response.json()
if isinstance(data, list) and data and data[0].get("investorsHolding"):
summary = data[0]
print("✅ RESULT: Institutional data AVAILABLE")
print(
f" {test_symbol}: {summary.get('investorsHolding')} holders, "
f"ownership {summary.get('ownershipPercent')}%"
)
return True
print("⚠️ RESULT: No institutional data for the latest quarter")
print(f" Data: {str(data)[:200]}")
return False
if response.status_code in (401, 403):
print("❌ RESULT: RESTRICTED (401/403) — check subscription/key")
return False
print(f"⚠️ RESULT: Unexpected status code {response.status_code}")
return False
except requests.exceptions.RequestException as e:
print(f"❌ ERROR: Request failed - {e}")
return False
if __name__ == "__main__":
print("=" * 70)
print("FMP Institutional-Holder Endpoint Availability Test")
print("=" * 70)
print()
result = check_institutional_endpoint()
print()
print("=" * 70)
sys.exit(0 if result else 1)
scripts/finviz_stock_client.py
#!/usr/bin/env python3
"""
Finviz Stock Data Client
Fetches individual stock data from Finviz including institutional ownership.
Uses simple web scraping with BeautifulSoup (no API key required).
Install: pip install beautifulsoup4 requests lxml
Usage:
from finviz_stock_client import FinvizStockClient
client = FinvizStockClient()
data = client.get_institutional_ownership("AAPL")
print(f"Inst Own: {data['inst_own_pct']}%")
"""
import sys
import time
from typing import Optional
try:
import requests
from bs4 import BeautifulSoup
except ImportError as _exc:
raise ImportError(
"required libraries not found. Install with: pip install beautifulsoup4 requests lxml"
) from _exc
class FinvizStockClient:
"""Client for fetching stock data from Finviz"""
BASE_URL = "https://finviz.com/quote.ashx"
def __init__(self, rate_limit_seconds: float = 2.0):
"""
Initialize Finviz client
Args:
rate_limit_seconds: Delay between requests to avoid rate limiting (default: 2.0s)
"""
self.rate_limit_seconds = rate_limit_seconds
self.last_request_time = 0.0
self.cache = {}
self.session = requests.Session()
# Use a realistic user agent to avoid blocking
self.session.headers.update(
{"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
)
def _rate_limited_fetch(self, symbol: str) -> Optional[dict]:
"""
Fetch stock data with rate limiting
Args:
symbol: Stock ticker symbol
Returns:
Dict with stock data, or None if fetch failed
"""
# Respect rate limit
elapsed = time.time() - self.last_request_time
if elapsed < self.rate_limit_seconds:
time.sleep(self.rate_limit_seconds - elapsed)
try:
url = f"{self.BASE_URL}?t={symbol}"
response = self.session.get(url, timeout=15)
self.last_request_time = time.time()
if response.status_code == 200:
return self._parse_finviz_page(response.text)
else:
print(
f"WARNING: Finviz request failed with status {response.status_code} for {symbol}",
file=sys.stderr,
)
return None
except Exception as e:
print(f"WARNING: Failed to fetch Finviz data for {symbol}: {e}", file=sys.stderr)
self.last_request_time = time.time()
return None
def _parse_finviz_page(self, html: str) -> dict:
"""
Parse Finviz stock page HTML
Args:
html: HTML content
Returns:
Dict with extracted data fields
"""
soup = BeautifulSoup(html, "lxml")
# Finviz uses a table structure with label-value pairs
data = {}
# Find all table rows containing data
tables = soup.find_all("table", {"class": "snapshot-table2"})
for table in tables:
rows = table.find_all("tr")
for row in rows:
cells = row.find_all("td")
# Each row has pairs of (label, value, label, value, ...)
for i in range(0, len(cells), 2):
if i + 1 < len(cells):
label = cells[i].get_text(strip=True)
value = cells[i + 1].get_text(strip=True)
data[label] = value
return data
def get_institutional_ownership(self, symbol: str) -> dict:
"""
Get institutional ownership data from Finviz
Args:
symbol: Stock ticker symbol
Returns:
Dict with:
- inst_own_pct: Institutional ownership percentage (float, or None)
- inst_trans_pct: Institutional transactions percentage (float, or None)
- error: Error message if data unavailable
Example:
>>> client = FinvizStockClient()
>>> data = client.get_institutional_ownership("AAPL")
>>> print(f"Inst Own: {data['inst_own_pct']}%")
Inst Own: 60.5%
"""
# Check cache first
cache_key = f"finviz_inst_{symbol}"
if cache_key in self.cache:
return self.cache[cache_key]
# Fetch from Finviz
stock_data = self._rate_limited_fetch(symbol)
if not stock_data:
result = {
"inst_own_pct": None,
"inst_trans_pct": None,
"error": f"Failed to fetch data from Finviz for {symbol}",
}
self.cache[cache_key] = result
return result
# Extract institutional data
# Finviz returns strings like "60.50%" or "-" if unavailable
inst_own_str = stock_data.get("Inst Own", "-")
inst_trans_str = stock_data.get("Inst Trans", "-")
# Parse percentage strings
inst_own_pct = self._parse_percentage(inst_own_str)
inst_trans_pct = self._parse_percentage(inst_trans_str)
result = {"inst_own_pct": inst_own_pct, "inst_trans_pct": inst_trans_pct, "error": None}
# Cache result
self.cache[cache_key] = result
return result
@staticmethod
def _parse_percentage(pct_str: str) -> Optional[float]:
"""
Parse percentage string from Finviz
Args:
pct_str: Percentage string like "60.50%" or "-"
Returns:
Float percentage value (60.50), or None if unavailable
"""
if not pct_str or pct_str == "-":
return None
try:
# Remove '%' and convert to float
return float(pct_str.rstrip("%"))
except (ValueError, AttributeError):
return None
def get_stock_data(self, symbol: str) -> Optional[dict]:
"""
Get full stock data dict from Finviz
Args:
symbol: Stock ticker symbol
Returns:
Dict with all Finviz data fields, or None if unavailable
"""
cache_key = f"finviz_full_{symbol}"
if cache_key in self.cache:
return self.cache[cache_key]
stock_data = self._rate_limited_fetch(symbol)
if stock_data:
self.cache[cache_key] = stock_data
return stock_data
# Example usage
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Test Finviz institutional ownership fetcher")
parser.add_argument("symbol", help="Stock ticker symbol")
args = parser.parse_args()
client = FinvizStockClient()
data = client.get_institutional_ownership(args.symbol)
print("\nFinviz Institutional Ownership Data:")
print(f" Symbol: {args.symbol}")
print(
f" Inst Own: {data['inst_own_pct']}%"
if data["inst_own_pct"] is not None
else " Inst Own: N/A"
)
print(
f" Inst Trans: {data['inst_trans_pct']}%"
if data["inst_trans_pct"] is not None
else " Inst Trans: N/A"
)
if data.get("error"):
print(f" Error: {data['error']}")
scripts/fmp_client.py
#!/usr/bin/env python3
# GENERATED by scripts/generate_fmp_client.py — do not edit.
# Source of truth: scripts/fmp_client/specials/ and scripts/fmp_client/registry.py.
# Regenerate: python3 scripts/generate_fmp_client.py
"""
FMP API Client for CANSLIM Screener
Provides rate-limited access to Financial Modeling Prep API endpoints
required for CANSLIM component analysis (C, A, N, M).
Features:
- Rate limiting (0.3s between requests)
- Automatic retry on 429 errors
- Session caching for duplicate requests
- Error handling and logging
"""
import os
import re
import sys
import time
from datetime import date, datetime, timedelta
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
import requests
except ImportError:
print("ERROR: requests library not found. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
# Mirror of scripts/provider_contracts.py::redact_url (clients are standalone).
# Requires a `?`/`&` prefix, so this masks apikey=/api_key= in URL query
# strings, not a rendered params dict (unreachable today: only
# RequestException text -- which can embed the full request URL -- is
# ever printed here, never a bare params repr()).
_APIKEY_RE = re.compile(r"([?&](?:apikey|api_key)=)[^&\s]+", re.IGNORECASE)
def _redact_key(text: str) -> str:
"""Mask apikey=/api_key= query values so stderr never carries the key."""
return _APIKEY_RE.sub(r"\1REDACTED", str(text))
# --- FMP endpoint fallback: stable (new users) -> v3 (legacy users) ---
def _et_today(now=None):
"""America/New_York calendar date for provider query windows (issue #427).
An explicit aware ``now`` wins (deterministic runs/tests); otherwise the
current instant is converted to America/New_York. ``now`` must be
timezone-aware when supplied. Fail-closed: missing TZ data raises
RuntimeError naming the tzdata requirement instead of silently falling
back to the runner's local clock.
"""
try:
tz = ZoneInfo("America/New_York")
except ZoneInfoNotFoundError as exc:
raise RuntimeError("America/New_York timezone data is missing; pip install tzdata") from exc
if now is not None:
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
return now.astimezone(tz).date()
return datetime.now(tz).date()
def _stable_quote_url(base, symbols_str, params):
"""stable/quote?symbol=^GSPC"""
params["symbol"] = symbols_str
return base, params
def _v3_quote_url(base, symbols_str, params):
"""api/v3/quote/^GSPC"""
return f"{base}/{symbols_str}", params
def _stable_hist_url(base, symbols_str, params):
"""stable/historical-price-eod/full?symbol=^GSPC&from=...&to=..."""
params["symbol"] = symbols_str
# New stable EOD endpoint ignores `timeseries`; convert to from/to range
# to bound the payload. Use 2x calendar days to cover N trading days
# (trading-day/calendar-day ratio ~252/365 ~0.69, so *2 leaves headroom).
days = params.pop("timeseries", None)
if days is not None:
today = _et_today()
params["from"] = (today - timedelta(days=int(days) * 2)).isoformat()
params["to"] = today.isoformat()
return base, params
def _v3_hist_url(base, symbols_str, params):
"""api/v3/historical-price-full/^GSPC?timeseries=80"""
return f"{base}/{symbols_str}", params
_FMP_ENDPOINTS = {
"quote": [
("https://financialmodelingprep.com/stable/quote", _stable_quote_url),
("https://financialmodelingprep.com/api/v3/quote", _v3_quote_url),
],
"historical": [
("https://financialmodelingprep.com/stable/historical-price-eod/full", _stable_hist_url),
("https://financialmodelingprep.com/api/v3/historical-price-full", _v3_hist_url),
],
}
def _normalize_eod_flat_list(data, symbols_str: str, limit: Optional[int] = None):
"""Convert stable/historical-price-eod/full flat list to v3-compatible dict.
Input : [{"symbol": "SPY", "date": "...", "open": ..., ...}, ...]
Output : {"symbol": "SPY", "historical": [{"date": ..., "open": ..., ...}, ...]}
Returns the input unchanged if not a list (passthrough for v3 dict /
historicalStockList responses). Returns None when no row matches the
requested symbol; the caller will record the failure and try the next
endpoint.
If `limit` is provided (the original `timeseries=N` request), the
`historical` list is truncated to the first `limit` entries. The new
EOD endpoint ignores `timeseries` and returns the full available history,
so the caller's date-range bounding plus this truncation together preserve
the legacy "most-recent N rows" contract. Truncation assumes descending
date order, which the FMP EOD endpoint provides (verified live).
Note: empty list ``[]`` does not reach this normalizer because the caller's
``if not data: continue`` falsy check handles it earlier in
``_request_with_fallback``.
"""
if not isinstance(data, list):
return data
if not data:
return None
norm_target = symbols_str.replace("-", ".")
matched_symbol = None
historical = []
for row in data:
if not isinstance(row, dict):
continue
# Be permissive: single-symbol endpoint may omit per-row "symbol".
# Treat missing symbol as belonging to the requested symbols_str.
row_sym = row.get("symbol") or symbols_str
if row_sym.replace("-", ".") != norm_target:
continue
matched_symbol = matched_symbol or row_sym
historical.append({k: v for k, v in row.items() if k != "symbol"})
if not historical:
return None
if limit is not None and limit > 0:
historical = historical[:limit]
return {"symbol": matched_symbol or symbols_str, "historical": historical}
class FMPClient:
"""Client for Financial Modeling Prep API with rate limiting and caching"""
BASE_URL = "https://financialmodelingprep.com/api/v3"
STABLE_URL = "https://financialmodelingprep.com/stable"
RATE_LIMIT_DELAY = 0.3 # 300ms between requests (200 requests/minute max)
def _redact_secret(self, text) -> str:
"""Mask raw and URL-encoded key echoes before diagnostics are truncated."""
from urllib.parse import quote, quote_plus
text = _redact_key(text)
if not self.api_key:
return text
patterns = {re.escape(self.api_key)}
for encoded in (quote(self.api_key, safe=""), quote_plus(self.api_key, safe="")):
# Percent triplets are case-insensitive; the key's letters are not.
parts = re.split(r"(%[0-9A-Fa-f]{2})", encoded)
patterns.add(
"".join(
"(?i:" + re.escape(part) + ")"
if re.fullmatch(r"%[0-9A-Fa-f]{2}", part)
else re.escape(part)
for part in parts
)
)
# One pass prevents a short key from matching the replacement marker.
return re.sub("|".join(sorted(patterns, key=len, reverse=True)), "***REDACTED***", text)
def __init__(self, api_key: Optional[str] = None):
"""
Initialize FMP API client
Args:
api_key: FMP API key (defaults to FMP_API_KEY environment variable)
Raises:
ValueError: If API key not provided and not in environment
"""
self.api_key = (api_key or os.getenv("FMP_API_KEY") or "").strip()
if not self.api_key:
raise ValueError(
"FMP API key required. Set FMP_API_KEY environment variable "
"or pass api_key parameter."
)
self.session = requests.Session()
self.session.headers.update({"apikey": self.api_key})
self.cache = {} # Simple in-memory cache for session
self.last_call_time = 0
self.rate_limit_reached = False
self.retry_count = 0
self.max_retries = 1
# Circuit breaker: track consecutive failures per endpoint URL prefix
self._endpoint_failures: dict[str, int] = {}
self._disabled_endpoints: set[str] = set()
self._ENDPOINT_FAILURE_THRESHOLD = 3
def _rate_limited_get(
self, url: str, params: Optional[dict] = None, quiet: bool = False
) -> Optional[dict]:
"""
Make rate-limited GET request with retry logic
Args:
url: Full endpoint URL
params: Query parameters (apikey sent via header)
quiet: If True, suppress non-429 error messages (used by fallback)
Returns:
JSON response dict, or None on error
"""
if self.rate_limit_reached:
return None
if params is None:
params = {}
# Enforce rate limit
elapsed = time.time() - self.last_call_time
if elapsed < self.RATE_LIMIT_DELAY:
time.sleep(self.RATE_LIMIT_DELAY - elapsed)
try:
response = self.session.get(url, params=params, timeout=30)
self.last_call_time = time.time()
if response.status_code == 200:
self.retry_count = 0 # Reset on success
return response.json()
elif response.status_code == 429:
# Rate limit exceeded
self.retry_count += 1
if self.retry_count <= self.max_retries:
print("WARNING: Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr)
time.sleep(60)
return self._rate_limited_get(url, params, quiet=quiet)
else:
print(
"ERROR: Daily API rate limit reached. Stopping analysis.", file=sys.stderr
)
self.rate_limit_reached = True
return None
else:
if not quiet:
print(
self._redact_secret(
f"ERROR: API request failed: {response.status_code} - {self._redact_secret(response.text)[:200]}"
),
file=sys.stderr,
)
return None
except requests.exceptions.RequestException as e:
print(self._redact_secret(f"ERROR: Request exception: {e}"), file=sys.stderr)
return None
def _request_with_fallback(self, endpoint_key, symbols_str, extra_params=None):
"""Try stable endpoint first, fall back to v3 with circuit breaker."""
params = dict(extra_params) if extra_params else {}
endpoints = _FMP_ENDPOINTS[endpoint_key]
is_single = "," not in symbols_str
for i, (base_url, url_builder) in enumerate(endpoints):
if base_url in self._disabled_endpoints:
continue
url, final_params = url_builder(base_url, symbols_str, dict(params))
is_last = i == len(endpoints) - 1
data = self._rate_limited_get(url, final_params, quiet=not is_last)
if not data:
self._record_endpoint_failure(base_url)
continue
# Normalize new stable EOD flat-list shape to v3-compatible dict.
# No-op for v3 dict / historicalStockList responses.
# `timeseries` (original request) is passed as `limit` so the
# EOD endpoint's full-history response is truncated to the
# legacy "most-recent N rows" contract.
if endpoint_key == "historical":
limit = params.get("timeseries") if isinstance(params, dict) else None
data = _normalize_eod_flat_list(data, symbols_str, limit=limit)
if not data:
self._record_endpoint_failure(base_url)
continue
valid = True
if endpoint_key == "quote":
if not isinstance(data, list) or len(data) == 0:
valid = False
elif is_single and not any(
q.get("symbol", "").replace("-", ".") == symbols_str.replace("-", ".")
for q in data
):
valid = False
if endpoint_key == "historical":
if not isinstance(data, dict):
valid = False
elif "historicalStockList" in data:
norm = symbols_str.replace("-", ".")
found = None
for entry in data["historicalStockList"]:
if entry.get("symbol", "").replace("-", ".") == norm:
found = {
"symbol": entry.get("symbol"),
"historical": entry.get("historical", []),
}
break
if found:
self._endpoint_failures[base_url] = 0
return found
valid = False
elif "historical" not in data:
valid = False
elif is_single and data.get("symbol"):
if data["symbol"].replace("-", ".") != symbols_str.replace("-", "."):
valid = False
if valid:
self._endpoint_failures[base_url] = 0
return data
self._record_endpoint_failure(base_url)
return None
def _record_endpoint_failure(self, base_url: str) -> None:
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)
def get_income_statement(
self, symbol: str, period: str = "quarter", limit: int = 8
) -> Optional[list[dict]]:
"""
Fetch income statement data (quarterly or annual)
Args:
symbol: Stock ticker (e.g., "AAPL")
period: "quarter" or "annual"
limit: Number of periods to fetch (default 8 for quarterly, 5 for annual)
Returns:
List of income statement records (most recent first), or None on error
Example:
quarterly = client.get_income_statement("AAPL", period="quarter", limit=8)
# Returns last 8 quarters (2 years) for YoY comparison
"""
cache_key = f"income_{symbol}_{period}_{limit}"
if cache_key in self.cache:
return self.cache[cache_key]
params = {"period": period, "limit": limit}
# stable: /income-statement?symbol=&period=&limit= ; v3 fallback: path-style
data = self._rate_limited_get(
f"{self.STABLE_URL}/income-statement", {**params, "symbol": symbol}, quiet=True
)
if not data:
data = self._rate_limited_get(f"{self.BASE_URL}/income-statement/{symbol}", params)
if data:
self.cache[cache_key] = data
return data
def get_quote(self, symbols: str) -> Optional[list[dict]]:
"""
Fetch real-time quote data
Args:
symbols: Single ticker or comma-separated list (e.g., "AAPL" or "AAPL,MSFT,GOOGL")
Returns:
List of quote records, or None on error
Example:
quote = client.get_quote("AAPL")
# Returns [{"symbol": "AAPL", "price": 185.92, "yearHigh": 198.23, ...}]
quotes = client.get_quote("^GSPC,^VIX")
# Batch fetch S&P 500 and VIX
"""
cache_key = f"quote_{symbols}"
if cache_key in self.cache:
return self.cache[cache_key]
data = self._request_with_fallback("quote", symbols)
if data:
self.cache[cache_key] = data
return data
def get_historical_prices(self, symbol: str, days: int = 365) -> Optional[dict]:
"""
Fetch historical daily price data
Args:
symbol: Stock ticker (e.g., "AAPL")
days: Number of days of history to fetch (default 365 for 52-week analysis)
Returns:
Dict with 'symbol' and 'historical' (list of daily OHLCV records), or None
Example:
prices = client.get_historical_prices("AAPL", days=365)
# prices['historical'][0] = most recent day
# prices['historical'][251] = 252 trading days ago (~1 year)
"""
cache_key = f"prices_{symbol}_{days}"
if cache_key in self.cache:
return self.cache[cache_key]
data = self._request_with_fallback("historical", symbol, {"timeseries": days})
if data:
self.cache[cache_key] = data
return data
def get_profile(self, symbol: str) -> Optional[list[dict]]:
"""
Fetch company profile (sector, industry, description)
Args:
symbol: Stock ticker
Returns:
List with single profile dict, or None on error
Example:
profile = client.get_profile("AAPL")
# profile[0] = {"symbol": "AAPL", "companyName": "Apple Inc.", "sector": "Technology", ...}
"""
cache_key = f"profile_{symbol}"
if cache_key in self.cache:
return self.cache[cache_key]
# stable: /profile?symbol= ; v3 fallback: /profile/{symbol}
data = self._rate_limited_get(f"{self.STABLE_URL}/profile", {"symbol": symbol}, quiet=True)
if not data:
data = self._rate_limited_get(f"{self.BASE_URL}/profile/{symbol}")
if data:
# /stable/profile renamed mktCap -> marketCap; expose mktCap so the
# screener (profile[0]["mktCap"]) keeps working on either endpoint.
for p in data:
if isinstance(p, dict) and "mktCap" not in p and "marketCap" in p:
p["mktCap"] = p["marketCap"]
self.cache[cache_key] = data
return data
@staticmethod
def _recent_13f_quarters(as_of=None, count: int = 4):
"""Yield the most recent completed (year, quarter) 13F periods, newest first.
13F filings lag the quarter end by ~45 days, so the just-completed
quarter may not be filed yet; the caller walks back until data exists.
"""
d = as_of or date.today()
year = d.year
quarter = (d.month - 1) // 3 # current quarter (1-4) minus 1 = last completed
if quarter == 0:
quarter, year = 4, year - 1
for _ in range(count):
yield year, quarter
quarter -= 1
if quarter == 0:
quarter, year = 4, year - 1
def get_institutional_holders(self, symbol: str) -> Optional[dict]:
"""Fetch institutional sponsorship summary (CANSLIM 'I' component).
Returns a dict::
{"num_holders": int, "ownership_pct": float | None,
"top_holders": [{"holder": str, "shares": int, "change": int}, ...]}
/stable has no single endpoint returning the full holder list (the
per-holder endpoint is paginated 10/page). Instead this uses
institutional-ownership/symbol-positions-summary for the holder count
(investorsHolding) and ownership % (ownershipPercent) — more accurate
than summing a list — plus extract-analytics/holder (page 0) for the
top names used in superinvestor detection. Falls back to the v3
institutional-holder list for legacy keys. Returns None on failure.
"""
cache_key = f"institutional_{symbol}"
if cache_key in self.cache:
return self.cache[cache_key]
result = None
# --- /stable: latest available 13F quarter ---
for year, quarter in self._recent_13f_quarters():
qp = {"symbol": symbol, "year": year, "quarter": quarter}
summary = self._rate_limited_get(
f"{self.STABLE_URL}/institutional-ownership/symbol-positions-summary",
qp,
quiet=True,
)
if isinstance(summary, list) and summary:
top = self._rate_limited_get(
f"{self.STABLE_URL}/institutional-ownership/extract-analytics/holder",
{**qp, "page": 0},
quiet=True,
)
top_holders = [
{
"holder": h.get("investorName"),
"shares": h.get("sharesNumber"),
"change": h.get("changeInSharesNumber"),
}
for h in (top or [])
if isinstance(h, dict)
]
result = {
"num_holders": summary[0].get("investorsHolding"),
"ownership_pct": summary[0].get("ownershipPercent"),
"top_holders": top_holders,
}
break
# --- v3 fallback (legacy keys): full holder list ---
if result is None:
v3 = self._rate_limited_get(f"{self.BASE_URL}/institutional-holder/{symbol}")
if isinstance(v3, list) and v3:
result = {
"num_holders": len(v3),
"ownership_pct": None, # calculator derives from shares/profile/Finviz
"top_holders": v3[:10], # already {holder, shares, change}
}
if result is not None:
self.cache[cache_key] = result
return result
def calculate_ema(self, prices: list[float], period: int = 50) -> float:
"""
Calculate Exponential Moving Average
Args:
prices: List of prices (most recent first)
period: EMA period (default 50)
Returns:
EMA value
Note:
This is a helper method for market direction (M component).
Uses standard EMA formula: EMA = Price * k + EMA_prev * (1-k)
where k = 2 / (period + 1)
"""
if len(prices) < period:
return sum(prices) / len(prices) # Fallback to simple average
# Reverse to oldest-first for calculation
prices_reversed = prices[::-1]
# Initialize with SMA
sma = sum(prices_reversed[:period]) / period
ema = sma
# Calculate EMA
k = 2 / (period + 1)
for price in prices_reversed[period:]:
ema = price * k + ema * (1 - k)
return ema
def clear_cache(self):
"""Clear session cache (useful for refreshing data)"""
self.cache = {}
print("Cache cleared", file=sys.stderr)
def get_api_stats(self) -> dict:
"""
Get API usage statistics for current session
Returns:
Dict with cache size and estimated API calls made
"""
return {
"cache_entries": len(self.cache),
"rate_limit_reached": self.rate_limit_reached,
"retry_count": self.retry_count,
}
def test_client():
"""Test FMP client with sample queries"""
print("Testing FMP Client...")
client = FMPClient()
# Test 1: Quote
print("\n1. Testing quote endpoint (AAPL)...")
quote = client.get_quote("AAPL")
if quote:
print(f"✓ Quote: {quote[0]['symbol']} @ ${quote[0]['price']:.2f}")
else:
print("✗ Quote failed")
# Test 2: Quarterly income statement
print("\n2. Testing quarterly income statement (AAPL)...")
quarterly = client.get_income_statement("AAPL", period="quarter", limit=8)
if quarterly:
latest = quarterly[0]
print(f"✓ Latest quarter: {latest['date']}, EPS: ${latest.get('eps', 'N/A')}")
else:
print("✗ Quarterly income statement failed")
# Test 3: Annual income statement
print("\n3. Testing annual income statement (AAPL)...")
annual = client.get_income_statement("AAPL", period="annual", limit=5)
if annual:
latest = annual[0]
print(f"✓ Latest year: {latest['date']}, EPS: ${latest.get('eps', 'N/A')}")
else:
print("✗ Annual income statement failed")
# Test 4: Historical prices
print("\n4. Testing historical prices (AAPL)...")
prices = client.get_historical_prices("AAPL", days=365)
if prices and "historical" in prices:
print(f"✓ Fetched {len(prices['historical'])} days of price history")
if len(prices["historical"]) > 0:
latest = prices["historical"][0]
print(f" Latest: {latest['date']}, Close: ${latest['close']:.2f}")
else:
print("✗ Historical prices failed")
# Test 5: Market indices (batch)
print("\n5. Testing market indices (^GSPC, ^VIX)...")
indices = client.get_quote("^GSPC,^VIX")
if indices:
for idx in indices:
print(f"✓ {idx['symbol']}: {idx['price']:.2f}")
else:
print("✗ Market indices failed")
# Test 6: Cache
print("\n6. Testing cache (repeat AAPL quote)...")
quote2 = client.get_quote("AAPL")
if quote2:
print("✓ Cache working (no API call made)")
# Stats
stats = client.get_api_stats()
print("\nAPI Stats:")
print(f" Cache entries: {stats['cache_entries']}")
print(f" Rate limit reached: {stats['rate_limit_reached']}")
print("\n✓ All tests completed")
if __name__ == "__main__":
test_client()
scripts/report_generator.py
#!/usr/bin/env python3
"""
CANSLIM Report Generator - Phase 3 (Full CANSLIM)
Generates JSON and Markdown reports for CANSLIM screening results.
Outputs:
- JSON: Structured data for programmatic use
- Markdown: Human-readable ranked list with component breakdowns
"""
import json
from datetime import datetime
def generate_json_report(results: list[dict], metadata: dict, output_file: str):
"""
Generate JSON report with screening results
Args:
results: List of analyzed stocks with scores
metadata: Screening metadata (date, parameters, etc.)
output_file: Output file path
"""
report = {"metadata": metadata, "results": results, "summary": generate_summary_stats(results)}
with open(output_file, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
print(f"✓ JSON report saved to: {output_file}")
def generate_markdown_report(results: list[dict], metadata: dict, output_file: str):
"""
Generate Markdown report with screening results
Args:
results: List of analyzed stocks with scores
metadata: Screening metadata
output_file: Output file path
"""
lines = []
# Header
lines.append("# CANSLIM Stock Screener Report - Phase 3 (Full CANSLIM)")
lines.append(f"**Generated:** {metadata['generated_at']}")
# Extract components from metadata
components = metadata.get("components_included", ["C", "A", "N", "S", "I", "M"])
components_str = ", ".join(components)
lines.append(f"**Phase:** {metadata['phase']} (Components: {components_str})")
lines.append(f"**Stocks Analyzed:** {metadata['candidates_analyzed']}")
schema_version = metadata.get("schema_version")
if schema_version:
lines.append(f"**Schema Version:** {schema_version}")
screening_options = metadata.get("screening_options") or {}
rs_benchmark = screening_options.get("rs_benchmark")
rs_disabled = screening_options.get("rs_disabled", False)
if rs_benchmark or rs_disabled:
lines.append(
f"**RS Benchmark:** {rs_benchmark or '^GSPC'}"
+ (" (DISABLED via --disable-rs)" if rs_disabled else "")
)
if rs_disabled:
lines.append("")
lines.append(
"> ⚠️ RS Disabled: L component fixed at neutral 50 via `--disable-rs`. "
"Composite scores are not directly comparable to full RS runs."
)
lines.append("")
lines.append("---")
lines.append("")
# Market condition summary
if "market_condition" in metadata:
market = metadata["market_condition"]
lines.append("## Market Condition Summary")
lines.append(f"- **Trend:** {market.get('trend', 'unknown')}")
lines.append(f"- **M Score:** {market.get('M_score', 'N/A')}/100")
if market.get("warning"):
lines.append(f"- **⚠️ Warning:** {market['warning']}")
if market.get("error"):
lines.append(f"- **⚠️ Data quality:** DEGRADED - M component: {market['error']}")
lines.append("")
lines.append("---")
lines.append("")
# Summary table (Phase 3.1: include RS rating + percentile for quick scanning)
if results:
lines.append("## Summary Table")
lines.append("")
lines.append("| # | Symbol | Score | Rating | RS Rating | RS % |")
lines.append("|---|--------|-------|--------|-----------|------|")
for idx, stock in enumerate(results, 1):
l_details = stock.get("l_component", {}) or {}
rs_rating = l_details.get("rs_rating", "N/A")
rs_percentile = l_details.get("rs_rank_percentile")
rs_pct_str = f"{rs_percentile}" if isinstance(rs_percentile, (int, float)) else "N/A"
lines.append(
f"| {idx} | {stock.get('symbol', 'N/A')} | "
f"{stock.get('composite_score', 0):.1f} | "
f"{stock.get('rating', 'N/A')} | {rs_rating} | {rs_pct_str} |"
)
lines.append("")
lines.append("---")
lines.append("")
# Top candidates
lines.append(f"## Top {len(results)} CANSLIM Candidates")
lines.append("")
for i, stock in enumerate(results, 1):
lines.extend(format_stock_entry(i, stock))
# Summary statistics
lines.append("---")
lines.append("")
lines.append("## Summary Statistics")
summary = generate_summary_stats(results)
lines.append(f"- **Total Stocks Screened:** {summary['total_stocks']}")
lines.append(f"- **Exceptional (90+):** {summary['exceptional']} stocks")
lines.append(f"- **Strong (80-89):** {summary['strong']} stocks")
lines.append(f"- **Above Average (70-79):** {summary['above_average']} stocks")
lines.append(f"- **Average (60-69):** {summary['average']} stocks")
lines.append(f"- **Below Average (<60):** {summary['below_average']} stocks")
lines.append("")
# Methodology note
lines.append("---")
lines.append("")
lines.append("## Methodology")
lines.append("")
lines.append("This Phase 3 implementation includes all 7 CANSLIM components (100% coverage):")
lines.append("")
lines.append("- **C** (Current Earnings) - 15% weight: Quarterly EPS growth YoY")
lines.append("- **A** (Annual Growth) - 20% weight: 3-year EPS CAGR")
lines.append("- **N** (Newness) - 15% weight: Price position vs 52-week high")
lines.append("- **S** (Supply/Demand) - 15% weight: Volume accumulation/distribution")
lines.append(
"- **L** (Leadership) - 20% weight: Multi-period weighted Relative Strength "
"(3m/6m/12m) vs configurable benchmark"
)
lines.append("- **I** (Institutional) - 10% weight: Institutional holder analysis")
lines.append("- **M** (Market Direction) - 5% weight: S&P 500 trend")
lines.append("")
lines.append("Component weights follow William O'Neil's original CANSLIM methodology,")
lines.append(
"with L (Leadership/RS Rank) as the most weighted component alongside A (Annual Growth)."
)
lines.append("")
lines.append("**Weighted RS Calculation (Phase 3.1):**")
lines.append("")
lines.append("```")
lines.append("Weighted RS = 0.40 × rel_3m + 0.30 × rel_6m + 0.30 × rel_12m")
lines.append(
"(When some periods are missing, the weights are re-normalized over available periods.)"
)
lines.append("Default benchmark: ^GSPC. Override with --rs-benchmark SPY/QQQ/IWM/...")
lines.append("```")
lines.append("")
lines.append("Fallback hierarchy when full multi-period data is not available:")
lines.append("")
lines.append(
"1. **No benchmark** → score from weighted absolute stock performance with a "
"20% penalty (legacy fallback)."
)
lines.append(
"2. **Multi-period unavailable but >=50 bars of price history** → fall back to "
"the legacy 365-day full-window absolute return as the scoring input. The 20% "
"penalty still applies when no benchmark is present."
)
lines.append(
"3. **<50 bars of price history** → score=0 with `error` set; no scoring is performed."
)
lines.append("")
lines.append("For detailed methodology, see `references/canslim_methodology.md`.")
lines.append("")
# Disclaimer
lines.append("---")
lines.append("")
lines.append(
"**Disclaimer:** This screener is for educational and informational purposes only. "
"Not investment advice. Conduct your own research and consult a financial advisor "
"before making investment decisions."
)
lines.append("")
# Write to file
with open(output_file, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"✓ Markdown report saved to: {output_file}")
def format_stock_entry(rank: int, stock: dict) -> list[str]:
"""
Format a single stock entry for Markdown report
Args:
rank: Stock rank (1-20)
stock: Stock data dict
Returns:
List of formatted lines
"""
lines = []
# Header with rank and rating emoji
rating_emoji = get_rating_emoji(stock["composite_score"])
lines.append(f"### {rank}. {stock['symbol']} - {stock['company_name']} {rating_emoji}")
# Basic info
price = stock.get("price")
price_str = f"${price:.2f}" if isinstance(price, (int, float)) else "N/A"
mc = stock.get("market_cap")
mc_str = f"${mc / 1e9:.1f}B" if isinstance(mc, (int, float)) else "N/A"
lines.append(
f"**Price:** {price_str} | "
f"**Market Cap:** {mc_str} | "
f"**Sector:** {stock.get('sector', 'N/A')}"
)
# Composite score
lines.append(f"**Composite Score:** {stock['composite_score']:.1f}/100 ({stock['rating']})")
lines.append("")
# Component breakdown table
lines.append("#### Component Breakdown")
lines.append("")
lines.append("| Component | Score | Details |")
lines.append("|-----------|-------|---------|")
# C component
c_details = stock.get("c_component", {})
c_score = c_details.get("score", 0)
c_eps = c_details.get("latest_qtr_eps_growth", "N/A")
c_rev = c_details.get("latest_qtr_revenue_growth", "N/A")
c_eps_str = f"{c_eps:+.1f}%" if isinstance(c_eps, (int, float)) else str(c_eps)
c_rev_str = f"{c_rev:+.1f}%" if isinstance(c_rev, (int, float)) else str(c_rev)
lines.append(f"| 🅲 Current Earnings | {c_score}/100 | EPS: {c_eps_str}, Revenue: {c_rev_str} |")
# A component
a_details = stock.get("a_component", {})
a_score = a_details.get("score", 0)
a_cagr = a_details.get("eps_cagr_3yr", "N/A")
a_stability = a_details.get("stability", "unknown")
a_cagr_str = f"{a_cagr:.1f}%" if isinstance(a_cagr, (int, float)) else str(a_cagr)
lines.append(f"| 🅰 Annual Growth | {a_score}/100 | 3yr CAGR: {a_cagr_str}, {a_stability} |")
# N component
n_details = stock.get("n_component", {})
n_score = n_details.get("score", 0)
n_distance = n_details.get("distance_from_high_pct", "N/A")
n_breakout = "✓ Breakout" if n_details.get("breakout_detected") else ""
n_distance_str = (
f"{n_distance:+.1f}%" if isinstance(n_distance, (int, float)) else str(n_distance)
)
lines.append(f"| 🅽 Newness | {n_score}/100 | {n_distance_str} from 52wk high {n_breakout} |")
# S component
s_details = stock.get("s_component", {})
s_score = s_details.get("score", 0)
s_ratio = s_details.get("up_down_ratio", "N/A")
s_accumulation = "✓ Accumulation" if s_details.get("accumulation_detected") else ""
s_ratio_str = f"{s_ratio:.2f}" if isinstance(s_ratio, (int, float)) else str(s_ratio)
lines.append(
f"| 🅂 Supply/Demand | {s_score}/100 | "
f"Up/Down Volume Ratio: {s_ratio_str} {s_accumulation} |"
)
# L component (Phase 3.1 - Leadership / Multi-period Relative Strength)
l_details = stock.get("l_component", {})
l_score = l_details.get("score", 0)
l_rs_rating = l_details.get("rs_rating")
l_rs_rank = l_details.get("rs_rank_percentile") or l_details.get("rs_rank_estimate")
if l_details.get("skipped"):
lines.append(f"| 🅻 Leadership | {l_score}/100 | Skipped via --disable-rs (neutral 50) |")
else:
# Multi-period breakdown (Phase 3.1) when available; fall back to legacy 52w view.
rs_3m = l_details.get("rs_3m_return")
rs_6m = l_details.get("rs_6m_return")
rs_12m = l_details.get("rs_12m_return")
rel_3m = l_details.get("rel_3m")
rel_6m = l_details.get("rel_6m")
rel_12m = l_details.get("rel_12m")
def _fmt(v):
return f"{v:+.1f}%" if isinstance(v, (int, float)) else "N/A"
if any(v is not None for v in (rs_3m, rs_6m, rs_12m)):
stock_str = f"{_fmt(rs_3m)}/{_fmt(rs_6m)}/{_fmt(rs_12m)}"
if any(v is not None for v in (rel_3m, rel_6m, rel_12m)):
rel_str = f" (rel {_fmt(rel_3m)}/{_fmt(rel_6m)}/{_fmt(rel_12m)})"
else:
rel_str = " (no benchmark)"
rs_str = (
f" | RS: {l_rs_rank} ({l_rs_rating})"
if isinstance(l_rs_rank, (int, float)) and l_rs_rating
else ""
)
lines.append(
f"| 🅻 Leadership | {l_score}/100 | 3m/6m/12m: {stock_str}{rel_str}{rs_str} |"
)
else:
# Legacy single-period rendering for backwards compatibility (e.g. when
# sp500_performance was supplied directly or when calling code uses an
# older leadership_calculator output shape).
l_stock_perf = l_details.get("stock_52w_performance")
l_relative = l_details.get("relative_performance")
stock_part = _fmt(l_stock_perf)
rel_part = (
f"{l_relative:+.1f}% vs benchmark"
if isinstance(l_relative, (int, float))
else "(no benchmark)"
)
rs_str = (
f" | RS: {l_rs_rank}" + (f" ({l_rs_rating})" if l_rs_rating else "")
if isinstance(l_rs_rank, (int, float))
else ""
)
lines.append(
f"| 🅻 Leadership | {l_score}/100 | 52wk: {stock_part} ({rel_part}){rs_str} |"
)
# I component
i_details = stock.get("i_component", {})
i_score = i_details.get("score", 0)
i_holders = i_details.get("num_holders", "N/A")
i_ownership = i_details.get("ownership_pct", "N/A")
i_ownership_str = (
f"{i_ownership:.1f}%" if isinstance(i_ownership, (int, float)) else str(i_ownership)
)
i_superinvestor = "⭐ Superinvestor" if i_details.get("superinvestor_present") else ""
lines.append(
f"| 🅸 Institutional | {i_score}/100 | "
f"{i_holders} holders, {i_ownership_str} ownership {i_superinvestor} |"
)
# M component
m_details = stock.get("m_component", {})
m_score = m_details.get("score", 0)
m_trend = m_details.get("trend", "unknown")
lines.append(f"| 🅼 Market Direction | {m_score}/100 | {m_trend.replace('_', ' ').title()} |")
lines.append("")
# Interpretation
lines.append("#### Interpretation")
lines.append(f"**Rating:** {stock['rating']} - {stock['rating_description']}")
lines.append("")
lines.append(f"**Guidance:** {stock['guidance']}")
lines.append("")
# Weakest component
lines.append(
f"**Weakest Component:** {stock['weakest_component']} ({stock['weakest_score']}/100)"
)
# Warnings
warnings = []
if c_details.get("quality_warning"):
warnings.append(f"⚠️ {c_details['quality_warning']}")
if a_details.get("quality_warning"):
warnings.append(f"⚠️ {a_details['quality_warning']}")
if s_details.get("quality_warning"):
warnings.append(f"⚠️ {s_details['quality_warning']}")
if l_details.get("quality_warning"):
warnings.append(f"⚠️ {l_details['quality_warning']}")
if i_details.get("quality_warning"):
warnings.append(f"⚠️ {i_details['quality_warning']}")
if m_details.get("warning"):
warnings.append(f"⚠️ {m_details['warning']}")
if warnings:
lines.append("")
lines.append("**Warnings:**")
for warning in warnings:
lines.append(f"- {warning}")
lines.append("")
lines.append("---")
lines.append("")
return lines
def get_rating_emoji(score: float) -> str:
"""Get emoji for rating"""
if score >= 90:
return "⭐⭐⭐" # Exceptional+
elif score >= 80:
return "⭐⭐" # Exceptional
elif score >= 70:
return "⭐" # Strong
elif score >= 60:
return "✓" # Above Average
else:
return ""
def generate_summary_stats(results: list[dict]) -> dict:
"""
Generate summary statistics for results
Args:
results: List of analyzed stocks
Returns:
Dict with summary statistics
"""
total = len(results)
exceptional = sum(1 for s in results if s["composite_score"] >= 90)
strong = sum(1 for s in results if 80 <= s["composite_score"] < 90)
above_avg = sum(1 for s in results if 70 <= s["composite_score"] < 80)
average = sum(1 for s in results if 60 <= s["composite_score"] < 70)
below_avg = sum(1 for s in results if s["composite_score"] < 60)
return {
"total_stocks": total,
"exceptional": exceptional,
"strong": strong,
"above_average": above_avg,
"average": average,
"below_average": below_avg,
}
# Example usage
if __name__ == "__main__":
print("Testing Report Generator...\n")
# Sample data
sample_results = [
{
"symbol": "NVDA",
"company_name": "NVIDIA Corporation",
"price": 495.50,
"market_cap": 1220000000000,
"sector": "Technology",
"composite_score": 97.2,
"rating": "Exceptional+",
"rating_description": "Rare multi-bagger setup",
"guidance": "Immediate buy, aggressive sizing",
"weakest_component": "A",
"weakest_score": 95,
"c_component": {
"score": 100,
"latest_qtr_eps_growth": 429,
"latest_qtr_revenue_growth": 101,
},
"a_component": {"score": 95, "eps_cagr_3yr": 76, "stability": "stable"},
"n_component": {"score": 98, "distance_from_high_pct": -0.5, "breakout_detected": True},
"m_component": {"score": 100, "trend": "strong_uptrend"},
},
{
"symbol": "META",
"company_name": "Meta Platforms Inc",
"price": 389.50,
"market_cap": 1000000000000,
"sector": "Technology",
"composite_score": 82.8,
"rating": "Exceptional",
"rating_description": "Outstanding fundamentals",
"guidance": "Strong buy, standard sizing",
"weakest_component": "A",
"weakest_score": 78,
"c_component": {
"score": 85,
"latest_qtr_eps_growth": 164,
"latest_qtr_revenue_growth": 23,
},
"a_component": {"score": 78, "eps_cagr_3yr": 28, "stability": "stable"},
"n_component": {
"score": 88,
"distance_from_high_pct": -5.0,
"breakout_detected": False,
},
"m_component": {"score": 80, "trend": "uptrend"},
},
]
sample_metadata = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC"),
"phase": "1 (MVP)",
"components_included": ["C", "A", "N", "M"],
"candidates_analyzed": 40,
"market_condition": {"trend": "uptrend", "M_score": 80, "warning": None},
}
# Generate reports
json_file = "test_canslim_report.json"
md_file = "test_canslim_report.md"
generate_json_report(sample_results, sample_metadata, json_file)
generate_markdown_report(sample_results, sample_metadata, md_file)
print("\n✓ Test reports generated successfully")
print(f" JSON: {json_file}")
print(f" Markdown: {md_file}")
scripts/scorer.py
#!/usr/bin/env python3
"""
CANSLIM Composite Scoring Engine - Phase 3 (Full CANSLIM)
Combines individual component scores into weighted composite score.
Supports Phase 1 (4 components), Phase 2 (6 components), and Phase 3 (7 components).
Phase 3 Weights (Full CANSLIM - Original O'Neil weights):
- C (Current Earnings): 15%
- A (Annual Growth): 20%
- N (Newness): 15%
- S (Supply/Demand): 15%
- L (Leadership/RS Rank): 20%
- I (Institutional): 10%
- M (Market Direction): 5%
Total: 100%
Interpretation Bands:
- 90-100: Exceptional+ (rare multi-bagger setup)
- 80-89: Exceptional (outstanding fundamentals)
- 70-79: Strong (solid across all components)
- 60-69: Above Average (meets thresholds)
- 50-59: Average (marginal)
- 40-49: Below Average (one or more weak)
- <40: Weak (fails CANSLIM criteria)
"""
# Phase 1 MVP component weights (renormalized from original CANSLIM)
WEIGHTS_PHASE1 = {
"C": 0.27, # Current Earnings (original 15%, renormalized to 27%)
"A": 0.36, # Annual Growth (original 20%, renormalized to 36%)
"N": 0.27, # Newness (original 15%, renormalized to 27%)
"M": 0.10, # Market Direction (original 5%, renormalized to 10%)
}
# Phase 2 component weights (6 components, renormalized excluding L)
WEIGHTS_PHASE2 = {
"C": 0.19, # Current Earnings (15% / 0.80 = 0.1875 ≈ 0.19)
"A": 0.25, # Annual Growth (20% / 0.80 = 0.25)
"N": 0.19, # Newness (15% / 0.80 = 0.1875 ≈ 0.19)
"S": 0.18, # Supply/Demand (15% / 0.80 = 0.1875, rounded down to keep total = 1.00)
"I": 0.13, # Institutional (10% / 0.80 = 0.125 ≈ 0.13)
"M": 0.06, # Market Direction (5% / 0.80 = 0.0625 ≈ 0.06)
}
# Phase 3 component weights (7 components, FULL CANSLIM - Original O'Neil weights)
WEIGHTS_PHASE3 = {
"C": 0.15, # Current Earnings - 15%
"A": 0.20, # Annual Growth - 20%
"N": 0.15, # Newness - 15%
"S": 0.15, # Supply/Demand - 15%
"L": 0.20, # Leadership/RS Rank - 20% (LARGEST component!)
"I": 0.10, # Institutional - 10%
"M": 0.05, # Market Direction - 5%
}
def calculate_composite_score(
c_score: float, a_score: float, n_score: float, m_score: float
) -> dict:
"""
Calculate weighted composite CANSLIM score for Phase 1 MVP
Args:
c_score: Current Earnings component score (0-100)
a_score: Annual Growth component score (0-100)
n_score: Newness component score (0-100)
m_score: Market Direction component score (0-100)
Returns:
Dict with:
- composite_score: Weighted average (0-100)
- rating: "Exceptional+", "Exceptional", "Strong", etc.
- rating_description: What the rating means
- guidance: Recommended action
- weakest_component: Component with lowest score
- weakest_score: Score of weakest component
Example:
>>> result = calculate_composite_score(c_score=95, a_score=90, n_score=88, m_score=100)
>>> print(f"{result['composite_score']:.1f} - {result['rating']}")
91.2 - Exceptional+
"""
# Calculate weighted composite
composite = (
c_score * WEIGHTS_PHASE1["C"]
+ a_score * WEIGHTS_PHASE1["A"]
+ n_score * WEIGHTS_PHASE1["N"]
+ m_score * WEIGHTS_PHASE1["M"]
)
# Identify weakest component
components = {"C": c_score, "A": a_score, "N": n_score, "M": m_score}
weakest_component = min(components, key=components.get)
weakest_score = components[weakest_component]
# Get rating and interpretation
rating_info = interpret_composite_score(composite)
return {
"composite_score": round(composite, 1),
"rating": rating_info["rating"],
"rating_description": rating_info["description"],
"guidance": rating_info["guidance"],
"weakest_component": weakest_component,
"weakest_score": weakest_score,
"component_scores": {"C": c_score, "A": a_score, "N": n_score, "M": m_score},
}
def interpret_composite_score(composite: float) -> dict:
"""
Interpret composite score and provide rating/guidance
Args:
composite: Composite score (0-100)
Returns:
Dict with rating, description, guidance
"""
if composite >= 90:
return {
"rating": "Exceptional+",
"description": "Rare multi-bagger setup - all components near-perfect",
"guidance": "Immediate buy, aggressive position sizing (15-20% of portfolio)",
}
elif composite >= 80:
return {
"rating": "Exceptional",
"description": "Outstanding fundamentals + strong momentum",
"guidance": "Strong buy, standard sizing (10-15% of portfolio)",
}
elif composite >= 70:
return {
"rating": "Strong",
"description": "Solid across all components, minor weaknesses",
"guidance": "Buy, standard sizing (8-12% of portfolio)",
}
elif composite >= 60:
return {
"rating": "Above Average",
"description": "Meets thresholds, one component weak",
"guidance": "Buy on pullback, conservative sizing (5-8% of portfolio)",
}
elif composite >= 50:
return {
"rating": "Average",
"description": "Marginal CANSLIM candidate",
"guidance": "Watchlist only, consider 3-5% if high conviction",
}
elif composite >= 40:
return {
"rating": "Below Average",
"description": "Fails one or more key thresholds",
"guidance": "Monitor, do not buy",
}
else:
return {
"rating": "Weak",
"description": "Does not meet CANSLIM criteria",
"guidance": "Avoid",
}
def check_minimum_thresholds(
c_score: float, a_score: float, n_score: float, m_score: float
) -> dict:
"""
Check if stock meets minimum CANSLIM thresholds
Minimum thresholds for "buy" consideration:
- C >= 60 (18%+ quarterly EPS growth)
- A >= 50 (25%+ annual EPS CAGR)
- N >= 40 (within 25% of 52-week high)
- M >= 40 (market not in downtrend)
Args:
c_score, a_score, n_score, m_score: Component scores
Returns:
Dict with:
- passes_all: Boolean - True if all thresholds met
- failed_components: List of components below threshold
- recommendation: "buy", "watchlist", or "avoid"
"""
thresholds = {"C": 60, "A": 50, "N": 40, "M": 40}
scores = {"C": c_score, "A": a_score, "N": n_score, "M": m_score}
failed = [comp for comp, threshold in thresholds.items() if scores[comp] < threshold]
# Special case: M score = 0 (bear market) overrides everything
if m_score == 0:
return {
"passes_all": False,
"failed_components": ["M"],
"recommendation": "avoid",
"reason": "Bear market - M component = 0. Do NOT buy regardless of C, A, N scores.",
}
if not failed:
return {
"passes_all": True,
"failed_components": [],
"recommendation": "buy",
"reason": "All minimum thresholds met",
}
elif len(failed) == 1:
return {
"passes_all": False,
"failed_components": failed,
"recommendation": "watchlist",
"reason": f"One component below threshold: {failed[0]}",
}
else:
return {
"passes_all": False,
"failed_components": failed,
"recommendation": "avoid",
"reason": f"Multiple components below threshold: {', '.join(failed)}",
}
def calculate_composite_score_phase2(
c_score: float, a_score: float, n_score: float, s_score: float, i_score: float, m_score: float
) -> dict:
"""
Calculate weighted composite CANSLIM score for Phase 2 (6 components)
Args:
c_score: Current Earnings component score (0-100)
a_score: Annual Growth component score (0-100)
n_score: Newness component score (0-100)
s_score: Supply/Demand component score (0-100)
i_score: Institutional component score (0-100)
m_score: Market Direction component score (0-100)
Returns:
Dict with:
- composite_score: Weighted average (0-100)
- rating: "Exceptional+", "Exceptional", "Strong", etc.
- rating_description: What the rating means
- guidance: Recommended action
- weakest_component: Component with lowest score
- weakest_score: Score of weakest component
Example:
>>> result = calculate_composite_score_phase2(
... c_score=95, a_score=90, n_score=88, s_score=85, i_score=92, m_score=100
... )
>>> print(f"{result['composite_score']:.1f} - {result['rating']}")
91.0 - Exceptional+
"""
# Calculate weighted composite
composite = (
c_score * WEIGHTS_PHASE2["C"]
+ a_score * WEIGHTS_PHASE2["A"]
+ n_score * WEIGHTS_PHASE2["N"]
+ s_score * WEIGHTS_PHASE2["S"]
+ i_score * WEIGHTS_PHASE2["I"]
+ m_score * WEIGHTS_PHASE2["M"]
)
# Identify weakest component
components = {
"C": c_score,
"A": a_score,
"N": n_score,
"S": s_score,
"I": i_score,
"M": m_score,
}
weakest_component = min(components, key=components.get)
weakest_score = components[weakest_component]
# Get rating and interpretation
rating_info = interpret_composite_score(composite)
return {
"composite_score": round(composite, 1),
"rating": rating_info["rating"],
"rating_description": rating_info["description"],
"guidance": rating_info["guidance"],
"weakest_component": weakest_component,
"weakest_score": weakest_score,
"component_scores": {
"C": c_score,
"A": a_score,
"N": n_score,
"S": s_score,
"I": i_score,
"M": m_score,
},
}
def check_minimum_thresholds_phase2(
c_score: float, a_score: float, n_score: float, s_score: float, i_score: float, m_score: float
) -> dict:
"""
Check if stock meets minimum CANSLIM thresholds (Phase 2: 6 components)
Minimum thresholds for "buy" consideration:
- C >= 60 (18%+ quarterly EPS growth)
- A >= 50 (25%+ annual EPS CAGR)
- N >= 40 (within 25% of 52-week high)
- S >= 40 (accumulation pattern, ratio ≥ 1.0)
- I >= 40 (30+ holders or 20%+ ownership)
- M >= 40 (market not in downtrend)
Args:
c_score, a_score, n_score, s_score, i_score, m_score: Component scores
Returns:
Dict with:
- passes_all: Boolean - True if all thresholds met
- failed_components: List of components below threshold
- recommendation: "buy", "watchlist", or "avoid"
"""
thresholds = {"C": 60, "A": 50, "N": 40, "S": 40, "I": 40, "M": 40}
scores = {"C": c_score, "A": a_score, "N": n_score, "S": s_score, "I": i_score, "M": m_score}
failed = [comp for comp, threshold in thresholds.items() if scores[comp] < threshold]
# Special case: M score = 0 (bear market) overrides everything
if m_score == 0:
return {
"passes_all": False,
"failed_components": ["M"],
"recommendation": "avoid",
"reason": "Bear market - M component = 0. Do NOT buy regardless of other scores.",
}
if not failed:
return {
"passes_all": True,
"failed_components": [],
"recommendation": "buy",
"reason": "All minimum thresholds met",
}
elif len(failed) == 1:
return {
"passes_all": False,
"failed_components": failed,
"recommendation": "watchlist",
"reason": f"One component below threshold: {failed[0]}",
}
else:
return {
"passes_all": False,
"failed_components": failed,
"recommendation": "avoid",
"reason": f"Multiple components below threshold: {', '.join(failed)}",
}
def calculate_composite_score_phase3(
c_score: float,
a_score: float,
n_score: float,
s_score: float,
l_score: float,
i_score: float,
m_score: float,
) -> dict:
"""
Calculate weighted composite CANSLIM score for Phase 3 (FULL 7 components)
This is the complete CANSLIM implementation with O'Neil's original weights.
Args:
c_score: Current Earnings component score (0-100)
a_score: Annual Growth component score (0-100)
n_score: Newness component score (0-100)
s_score: Supply/Demand component score (0-100)
l_score: Leadership/RS Rank component score (0-100)
i_score: Institutional component score (0-100)
m_score: Market Direction component score (0-100)
Returns:
Dict with:
- composite_score: Weighted average (0-100)
- rating: "Exceptional+", "Exceptional", "Strong", etc.
- rating_description: What the rating means
- guidance: Recommended action
- weakest_component: Component with lowest score
- weakest_score: Score of weakest component
Example:
>>> result = calculate_composite_score_phase3(
... c_score=95, a_score=90, n_score=88, s_score=85,
... l_score=92, i_score=80, m_score=100
... )
>>> print(f"{result['composite_score']:.1f} - {result['rating']}")
89.7 - Exceptional
"""
# Calculate weighted composite using FULL CANSLIM weights
composite = (
c_score * WEIGHTS_PHASE3["C"]
+ a_score * WEIGHTS_PHASE3["A"]
+ n_score * WEIGHTS_PHASE3["N"]
+ s_score * WEIGHTS_PHASE3["S"]
+ l_score * WEIGHTS_PHASE3["L"]
+ i_score * WEIGHTS_PHASE3["I"]
+ m_score * WEIGHTS_PHASE3["M"]
)
# Identify weakest component
components = {
"C": c_score,
"A": a_score,
"N": n_score,
"S": s_score,
"L": l_score,
"I": i_score,
"M": m_score,
}
weakest_component = min(components, key=components.get)
weakest_score = components[weakest_component]
# Get rating and interpretation
rating_info = interpret_composite_score(composite)
return {
"composite_score": round(composite, 1),
"rating": rating_info["rating"],
"rating_description": rating_info["description"],
"guidance": rating_info["guidance"],
"weakest_component": weakest_component,
"weakest_score": weakest_score,
"component_scores": {
"C": c_score,
"A": a_score,
"N": n_score,
"S": s_score,
"L": l_score,
"I": i_score,
"M": m_score,
},
}
def check_minimum_thresholds_phase3(
c_score: float,
a_score: float,
n_score: float,
s_score: float,
l_score: float,
i_score: float,
m_score: float,
) -> dict:
"""
Check if stock meets minimum CANSLIM thresholds (Phase 3: FULL 7 components)
Minimum thresholds for "buy" consideration:
- C >= 60 (18%+ quarterly EPS growth)
- A >= 50 (25%+ annual EPS CAGR)
- N >= 40 (within 25% of 52-week high)
- S >= 40 (accumulation pattern, ratio >= 1.0)
- L >= 50 (RS Rank 60+, outperforming market)
- I >= 40 (30+ holders or 20%+ ownership)
- M >= 40 (market not in downtrend)
Args:
c_score, a_score, n_score, s_score, l_score, i_score, m_score: Component scores
Returns:
Dict with:
- passes_all: Boolean - True if all thresholds met
- failed_components: List of components below threshold
- recommendation: "buy", "watchlist", or "avoid"
"""
thresholds = {"C": 60, "A": 50, "N": 40, "S": 40, "L": 50, "I": 40, "M": 40}
scores = {
"C": c_score,
"A": a_score,
"N": n_score,
"S": s_score,
"L": l_score,
"I": i_score,
"M": m_score,
}
failed = [comp for comp, threshold in thresholds.items() if scores[comp] < threshold]
# Special case: M score = 0 (bear market) overrides everything
if m_score == 0:
return {
"passes_all": False,
"failed_components": ["M"],
"recommendation": "avoid",
"reason": "Bear market - M component = 0. Do NOT buy regardless of other scores.",
}
# Special case: L score < 40 (major laggard) - strong warning
if l_score < 40:
if "L" not in failed:
failed.append("L")
return {
"passes_all": False,
"failed_components": failed,
"recommendation": "avoid",
"reason": f"Stock significantly underperforming market (L={l_score}). CANSLIM requires market leaders.",
}
if not failed:
return {
"passes_all": True,
"failed_components": [],
"recommendation": "buy",
"reason": "All 7 CANSLIM thresholds met - Full methodology validation",
}
elif len(failed) == 1:
return {
"passes_all": False,
"failed_components": failed,
"recommendation": "watchlist",
"reason": f"One component below threshold: {failed[0]}",
}
else:
return {
"passes_all": False,
"failed_components": failed,
"recommendation": "avoid",
"reason": f"Multiple components below threshold: {', '.join(failed)}",
}
def compare_to_full_canslim(phase1_score: float) -> dict:
"""
Estimate what Phase 1 MVP score would be with full 7-component CANSLIM
Phase 1 (4 components) represents 55% of full CANSLIM methodology.
This function estimates the equivalent full CANSLIM score.
Args:
phase1_score: Phase 1 composite score (0-100)
Returns:
Dict with:
- estimated_full_score: Estimated score with all 7 components (0-200 scale)
- equivalent_rating: Rating on full CANSLIM scale
- note: Explanation of estimation
"""
# Phase 1 score 80+ typically indicates exceptional fundamentals
# In full CANSLIM, this would likely score 140-160+ (top tier)
if phase1_score >= 90:
estimated_range = "160-200"
equivalent_rating = "Exceptional"
elif phase1_score >= 80:
estimated_range = "140-159"
equivalent_rating = "Strong"
elif phase1_score >= 70:
estimated_range = "120-139"
equivalent_rating = "Above Average"
elif phase1_score >= 60:
estimated_range = "105-119"
equivalent_rating = "Average"
else:
estimated_range = "<105"
equivalent_rating = "Below Average"
return {
"phase1_score": phase1_score,
"estimated_full_range": estimated_range,
"equivalent_rating": equivalent_rating,
"note": (
"Phase 1 implements 4 of 7 components (55% of methodology). "
"Full CANSLIM (Phases 2-3) will add S, L, I components."
),
}
# Example usage and testing
if __name__ == "__main__":
print("Testing CANSLIM Scorer (Phase 1 MVP)...\n")
# Test 1: Exceptional stock (NVDA-like)
print("Test 1: Exceptional Stock (All Components Strong)")
result1 = calculate_composite_score(c_score=100, a_score=95, n_score=98, m_score=100)
print(f" Composite Score: {result1['composite_score']}/100")
print(f" Rating: {result1['rating']}")
print(f" Description: {result1['rating_description']}")
print(f" Guidance: {result1['guidance']}")
print(f" Weakest Component: {result1['weakest_component']} ({result1['weakest_score']})\n")
full1 = compare_to_full_canslim(result1["composite_score"])
print(f" Estimated Full CANSLIM Range: {full1['estimated_full_range']}\n")
# Test 2: Strong stock (META-like)
print("Test 2: Strong Stock (Most Components Good)")
result2 = calculate_composite_score(c_score=85, a_score=78, n_score=88, m_score=80)
print(f" Composite Score: {result2['composite_score']}/100")
print(f" Rating: {result2['rating']}")
print(f" Weakest Component: {result2['weakest_component']} ({result2['weakest_score']})\n")
# Test 3: Average stock (marginal)
print("Test 3: Average Stock (Meets Minimums)")
result3 = calculate_composite_score(c_score=60, a_score=55, n_score=65, m_score=60)
print(f" Composite Score: {result3['composite_score']}/100")
print(f" Rating: {result3['rating']}")
print(f" Guidance: {result3['guidance']}\n")
# Test 4: Bear market scenario (M=0)
print("Test 4: Bear Market Scenario (M=0 Override)")
result4 = calculate_composite_score(c_score=95, a_score=90, n_score=92, m_score=0)
print(f" Composite Score: {result4['composite_score']}/100")
print(f" Rating: {result4['rating']}")
threshold_check = check_minimum_thresholds(c_score=95, a_score=90, n_score=92, m_score=0)
print(f" Threshold Check: {threshold_check['recommendation']}")
print(f" Reason: {threshold_check['reason']}\n")
# Test 5: Minimum threshold checking
print("Test 5: Threshold Validation")
threshold_pass = check_minimum_thresholds(c_score=70, a_score=60, n_score=65, m_score=70)
print(f" All thresholds met: {threshold_pass['passes_all']}")
print(f" Recommendation: {threshold_pass['recommendation']}\n")
threshold_fail = check_minimum_thresholds(c_score=50, a_score=45, n_score=35, m_score=60)
print(f" All thresholds met: {threshold_fail['passes_all']}")
print(f" Failed components: {threshold_fail['failed_components']}")
print(f" Recommendation: {threshold_fail['recommendation']}")
print("\n✓ All tests completed")
scripts/screen_canslim.py
#!/usr/bin/env python3
"""
CANSLIM Stock Screener - Phase 3 (Full CANSLIM)
Screens US stocks using William O'Neil's CANSLIM methodology.
Phase 3 implements all 7 components: C, A, N, S, L, I, M (100% coverage)
Usage:
python3 screen_canslim.py --api-key YOUR_KEY --max-candidates 40
python3 screen_canslim.py # Uses FMP_API_KEY environment variable
Output:
- JSON: canslim_screener_YYYY-MM-DD_HHMMSS.json
- Markdown: canslim_screener_YYYY-MM-DD_HHMMSS.md
"""
import argparse
import os
import sys
from datetime import datetime
from typing import Optional
# Add calculators directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "calculators"))
from calculators.earnings_calculator import calculate_quarterly_growth
from calculators.growth_calculator import calculate_annual_growth
from calculators.institutional_calculator import calculate_institutional_sponsorship
from calculators.leadership_calculator import calculate_leadership
from calculators.market_calculator import calculate_market_direction
from calculators.new_highs_calculator import calculate_newness
from calculators.supply_demand_calculator import calculate_supply_demand
from fmp_client import FMPClient
from report_generator import generate_json_report, generate_markdown_report
from scorer import (
calculate_composite_score_phase3,
check_minimum_thresholds_phase3,
)
# S&P 500 sample tickers (top 40 by market cap)
DEFAULT_UNIVERSE = [
"AAPL",
"MSFT",
"GOOGL",
"AMZN",
"NVDA",
"META",
"TSLA",
"BRK.B",
"UNH",
"JNJ",
"XOM",
"V",
"PG",
"JPM",
"MA",
"HD",
"CVX",
"MRK",
"ABBV",
"PEP",
"COST",
"AVGO",
"KO",
"ADBE",
"LLY",
"TMO",
"WMT",
"MCD",
"CSCO",
"ACN",
"ORCL",
"ABT",
"NKE",
"CRM",
"DHR",
"VZ",
"TXN",
"AMD",
"QCOM",
"INTC",
]
def parse_arguments():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(
description="CANSLIM Stock Screener - Phase 3 (Full CANSLIM: C, A, N, S, L, I, M)"
)
parser.add_argument(
"--api-key", help="FMP API key (defaults to FMP_API_KEY environment variable)"
)
parser.add_argument(
"--max-candidates",
type=int,
default=40,
help="Maximum number of stocks to analyze (default: 40; use 35 for free tier's 250 calls/day limit)",
)
parser.add_argument(
"--top",
type=int,
default=20,
help="Number of top stocks to include in report (default: 20)",
)
parser.add_argument(
"--output-dir",
default="reports/",
help="Output directory for reports (default: reports/)",
)
parser.add_argument(
"--universe",
nargs="+",
help="Custom list of stock symbols to screen (overrides default S&P 500)",
)
parser.add_argument(
"--rs-benchmark",
default="^GSPC",
help=(
"Benchmark symbol for L-component Relative Strength (default: ^GSPC). "
"Examples: SPY, QQQ, IWM. The M component continues to use ^GSPC for "
"EMA scale consistency regardless of this flag."
),
)
parser.add_argument(
"--disable-rs",
action="store_true",
help=(
"Skip L component calculation (saves the per-stock 365-day price fetch; "
"also skips the custom RS benchmark fetch when applicable). "
"L score is set to neutral 50."
),
)
return parser.parse_args()
def analyze_stock(
symbol: str,
client: FMPClient,
market_data: dict,
rs_benchmark_historical: Optional[dict] = None,
rs_benchmark: str = "^GSPC",
disable_rs: bool = False,
) -> Optional[dict]:
"""
Analyze a single stock using CANSLIM Phase 3 components (7 components: C, A, N, S, L, I, M)
Args:
symbol: Stock ticker
client: FMP API client
market_data: Pre-calculated market direction data
rs_benchmark_historical: RS benchmark historical prices (FMP response shape) for the
L component's relative-strength calculation. May be None when
the benchmark fetch failed; the L calculator falls back to
absolute performance with a 20% penalty in that case.
rs_benchmark: Benchmark symbol surfaced into the L component output (e.g. "^GSPC", "SPY").
disable_rs: When True, skip the per-stock 365-day fetch and emit a neutral L=50 result.
Returns:
Dict with analysis results, or None if analysis failed
"""
print(f" Analyzing {symbol}...", end=" ", flush=True)
try:
# Get company profile
profile = client.get_profile(symbol)
if not profile:
print("✗ Profile unavailable")
return None
company_name = profile[0].get("companyName", symbol)
sector = profile[0].get("sector", "Unknown")
market_cap = profile[0].get("mktCap", 0)
# Get quote
quote = client.get_quote(symbol)
if not quote:
print("✗ Quote unavailable")
return None
price = quote[0].get("price", 0)
# C Component: Current Quarterly Earnings
quarterly_income = client.get_income_statement(symbol, period="quarter", limit=8)
c_result = (
calculate_quarterly_growth(quarterly_income)
if quarterly_income
else {"score": 0, "error": "No quarterly data"}
)
# A Component: Annual Growth
annual_income = client.get_income_statement(symbol, period="annual", limit=5)
a_result = (
calculate_annual_growth(annual_income)
if annual_income
else {"score": 50, "error": "No annual data"}
)
# N Component: Newness / New Highs
n_result = calculate_newness(quote[0])
# S Component: Supply/Demand (uses existing historical_prices data - no extra API call)
historical_prices = client.get_historical_prices(symbol, days=90)
s_result = (
calculate_supply_demand(historical_prices)
if historical_prices
else {"score": 0, "error": "No price history data"}
)
# L Component: Leadership / Relative Strength
# When --disable-rs is set, skip the 365-day fetch entirely and emit a neutral
# placeholder so downstream composite scoring still has a value to multiply by.
if disable_rs:
# Mirror the full Phase 3.1 l_component schema so downstream consumers
# (JSON parsers, report templates, postmortem tools) can read fields
# uniformly without special-casing the disable-rs branch. Multi-period
# numeric fields are None; available_periods is empty; missing_periods
# lists every configured window.
l_result = {
"score": 50,
"skipped": True,
"reason": "Disabled by --disable-rs",
# Legacy fields
"stock_52w_performance": None,
"sp500_52w_performance": None,
"relative_performance": None,
"rs_rank_estimate": None,
"days_analyzed": 0,
"interpretation": "L component skipped via --disable-rs (neutral 50)",
"quality_warning": None,
"error": None,
# Phase 3.1 multi-period fields
"rs_3m_return": None,
"rs_6m_return": None,
"rs_12m_return": None,
"benchmark_3m_return": None,
"benchmark_6m_return": None,
"benchmark_12m_return": None,
"rel_3m": None,
"rel_6m": None,
"rel_12m": None,
"weighted_stock_performance": None,
"weighted_relative_performance": None,
"available_periods": [],
"missing_periods": ["3m", "6m", "12m"],
"benchmark_52w_performance": None,
"rs_benchmark": rs_benchmark,
"rs_benchmark_relative_return": None,
"rs_rating": "Skipped",
"rs_component_score": 50,
"rs_rank_percentile": None,
}
else:
historical_prices_52w_data = client.get_historical_prices(symbol, days=365)
historical_prices_52w = (
historical_prices_52w_data.get("historical", [])
if historical_prices_52w_data
else []
)
rs_benchmark_list = (
rs_benchmark_historical.get("historical", []) if rs_benchmark_historical else None
)
l_result = (
calculate_leadership(
historical_prices_52w,
sp500_historical=rs_benchmark_list,
rs_benchmark=rs_benchmark,
)
if historical_prices_52w
else {
"score": 0,
"error": "No 52-week price history",
"rs_benchmark": rs_benchmark,
"rs_component_score": 0,
"rs_rating": "Weak",
"rs_rank_percentile": None,
}
)
# I Component: Institutional Sponsorship (with Finviz fallback)
institutional_holders = client.get_institutional_holders(symbol)
i_result = calculate_institutional_sponsorship(
institutional_holders, profile[0], symbol=symbol, use_finviz_fallback=True
)
# M Component: Market Direction (use pre-calculated)
m_result = market_data
# Calculate composite score (Phase 3: 7 components - FULL CANSLIM)
composite = calculate_composite_score_phase3(
c_score=c_result.get("score", 0),
a_score=a_result.get("score", 50),
n_score=n_result.get("score", 0),
s_score=s_result.get("score", 0),
l_score=l_result.get("score", 0),
i_score=i_result.get("score", 0),
m_score=m_result.get("score", 50),
)
# Check minimum thresholds (Phase 3)
threshold_check = check_minimum_thresholds_phase3(
c_score=c_result.get("score", 0),
a_score=a_result.get("score", 50),
n_score=n_result.get("score", 0),
s_score=s_result.get("score", 0),
l_score=l_result.get("score", 0),
i_score=i_result.get("score", 0),
m_score=m_result.get("score", 50),
)
print(f"✓ Score: {composite['composite_score']:.1f} ({composite['rating']})")
return {
"symbol": symbol,
"company_name": company_name,
"sector": sector,
"price": price,
"market_cap": market_cap,
"composite_score": composite["composite_score"],
"rating": composite["rating"],
"rating_description": composite["rating_description"],
"guidance": composite["guidance"],
"weakest_component": composite["weakest_component"],
"weakest_score": composite["weakest_score"],
"c_component": c_result,
"a_component": a_result,
"n_component": n_result,
"s_component": s_result,
"l_component": l_result, # NEW: Phase 3
"i_component": i_result,
"m_component": m_result,
"threshold_check": threshold_check,
}
except Exception as e:
print(f"✗ Error: {e}")
return None
def main():
"""Main screening workflow"""
args = parse_arguments()
print("=" * 60)
print("CANSLIM Stock Screener - Phase 3 (Full CANSLIM)")
print(
"Components: C (Earnings), A (Growth), N (Newness), S (Supply/Demand), L (Leadership), I (Institutional), M (Market)"
)
print("=" * 60)
print()
# Initialize FMP client
try:
client = FMPClient(api_key=args.api_key)
print("✓ FMP API client initialized")
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# Determine universe
if args.universe:
universe = args.universe[: args.max_candidates]
print(f"✓ Custom universe: {len(universe)} stocks")
else:
universe = DEFAULT_UNIVERSE[: args.max_candidates]
print(f"✓ Default universe (S&P 500 top {len(universe)}): {len(universe)} stocks")
print()
# Step 1: Calculate market direction (M component) once for all stocks
print("Step 1: Analyzing Market Direction (M Component)")
print("-" * 60)
sp500_quote = client.get_quote("^GSPC")
vix_quote = client.get_quote("^VIX")
if not sp500_quote:
print("ERROR: Unable to fetch S&P 500 data", file=sys.stderr)
sys.exit(1)
# Fetch ^GSPC historical prices for the M component. ^GSPC must remain the
# benchmark for the M component to keep scale consistent with the ^GSPC quote
# (see test_canslim_fixes.py::TestBenchmarkScaleConsistency).
print("Fetching ^GSPC 52-week data for M component (EMA)...")
market_sp500_historical = client.get_historical_prices("^GSPC", days=365)
if market_sp500_historical and market_sp500_historical.get("historical"):
market_days = len(market_sp500_historical.get("historical", []))
print(f"✓ ^GSPC historical data: {market_days} days")
else:
print("⚠️ ^GSPC historical data unavailable - M component will use EMA fallback")
# Resolve the L component's benchmark fetch. When the user kept the default
# ^GSPC, reuse the already-fetched series (FMPClient cache also covers this,
# but the explicit reuse here documents the intent). When --disable-rs is
# set, skip the benchmark fetch entirely.
rs_benchmark_historical = None
if not args.disable_rs:
if args.rs_benchmark == "^GSPC":
rs_benchmark_historical = market_sp500_historical
else:
print(
f"Fetching {args.rs_benchmark} 52-week data for L component (Relative Strength)..."
)
rs_benchmark_historical = client.get_historical_prices(args.rs_benchmark, days=365)
if rs_benchmark_historical and rs_benchmark_historical.get("historical"):
rs_days = len(rs_benchmark_historical.get("historical", []))
print(f"✓ {args.rs_benchmark} historical data: {rs_days} days")
else:
print(
f"⚠️ {args.rs_benchmark} historical data unavailable - "
"L component will fall back to absolute performance with 20% penalty"
)
else:
print("⚠️ --disable-rs set: L component will be fixed at neutral 50 (no RS fetch)")
# Calculate M component using real ^GSPC historical prices for accurate EMA
market_sp500_list = (
market_sp500_historical.get("historical", []) if market_sp500_historical else []
)
market_data = calculate_market_direction(
sp500_quote=sp500_quote[0],
sp500_prices=market_sp500_list if market_sp500_list else None,
vix_quote=vix_quote[0] if vix_quote else None,
)
# A quote without a usable price is as unusable as no quote: fail closed rather
# than rank candidates on a fabricated neutral M score.
if market_data.get("sp500_price") is None:
print(
f"ERROR: Unable to determine market direction: {market_data.get('error')}",
file=sys.stderr,
)
sys.exit(1)
print(f"S&P 500: ${market_data['sp500_price']:.2f}")
_dist = market_data.get("distance_from_ema_pct")
if isinstance(_dist, (int, float)):
print(f"Distance from 50-EMA: {_dist:+.2f}%")
else:
print("Distance from 50-EMA: N/A (insufficient history)")
print(f"Trend: {market_data['trend']}")
print(f"M Score: {market_data['score']}/100")
print(f"Interpretation: {market_data['interpretation']}")
if market_data.get("warning"):
print()
print(f"⚠️ WARNING: {market_data['warning']}")
print(" Consider raising cash allocation. CANSLIM doesn't work in bear markets.")
print()
# Step 2: Progressive filtering and analysis
print(f"Step 2: Analyzing {len(universe)} Stocks")
print("-" * 60)
results = []
for symbol in universe:
analysis = analyze_stock(
symbol,
client,
market_data,
rs_benchmark_historical=rs_benchmark_historical,
rs_benchmark=args.rs_benchmark,
disable_rs=args.disable_rs,
)
if analysis:
results.append(analysis)
print()
print(f"✓ Successfully analyzed {len(results)} stocks")
print()
# Step 3: Rank by composite score
print("Step 3: Ranking Results")
print("-" * 60)
results.sort(key=lambda x: x["composite_score"], reverse=True)
# Display top 5
print("Top 5 Stocks:")
for i, stock in enumerate(results[:5], 1):
print(f" {i}. {stock['symbol']:6} - {stock['composite_score']:5.1f} ({stock['rating']})")
print()
# Step 4: Generate reports
print("Step 4: Generating Reports")
print("-" * 60)
os.makedirs(args.output_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_file = os.path.join(args.output_dir, f"canslim_screener_{timestamp}.json")
md_file = os.path.join(args.output_dir, f"canslim_screener_{timestamp}.md")
metadata = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC"),
"schema_version": "3.1",
"phase": "3.1 (7 components - FULL CANSLIM with multi-period RS)",
"components_included": ["C", "A", "N", "S", "L", "I", "M"],
"candidates_analyzed": len(results),
"universe_size": len(universe),
"screening_options": {
"rs_benchmark": args.rs_benchmark,
"rs_disabled": args.disable_rs,
},
"market_condition": {
"trend": market_data["trend"],
"M_score": market_data["score"],
"warning": market_data.get("warning"),
# Set when the M score is a neutral fallback rather than a measured trend.
"error": market_data.get("error"),
},
}
# Limit to top N for report
top_results = results[: args.top]
generate_json_report(top_results, metadata, json_file)
generate_markdown_report(top_results, metadata, md_file)
print()
print("=" * 60)
print("✓ CANSLIM Screening Complete")
print("=" * 60)
print(f" JSON Report: {json_file}")
print(f" Markdown Report: {md_file}")
print()
# API stats
api_stats = client.get_api_stats()
print("API Usage:")
print(f" Cache entries: {api_stats['cache_entries']}")
if args.disable_rs:
# Per-stock 365-day RS fetch is skipped; M-side market calls remain
# (^GSPC quote + VIX quote + ^GSPC 365-day = 3 calls).
print(
f" Estimated calls: ~{len(universe) * 6 + 3} "
f"(3 market data calls + {len(universe)} stocks × 6 API calls each, --disable-rs)"
)
else:
# Custom benchmark adds one extra fetch when it differs from ^GSPC.
market_calls = 3 if args.rs_benchmark == "^GSPC" else 4
print(
f" Estimated calls: ~{len(universe) * 7 + market_calls} "
f"({market_calls} market data calls + {len(universe)} stocks × 7 API calls each)"
)
print(" Phase 3.1 includes all 7 CANSLIM components (C, A, N, S, L, I, M)")
print()
if __name__ == "__main__":
main()
scripts/tests/conftest.py
"""Shared fixtures for CANSLIM Screener tests"""
import os
import sys
# Add scripts directory to path so modules can be imported
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Add calculators subdirectory
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "calculators"))
# Add tests directory to path so helpers can be imported
sys.path.insert(0, os.path.dirname(__file__))
scripts/tests/test_canslim_calculators_coverage.py
#!/usr/bin/env python3
"""Coverage tests for the CANSLIM component calculators (C, A, N, S, L, I, M)."""
import pytest
from calculators import institutional_calculator as inst
from calculators.earnings_calculator import (
calculate_quarterly_growth,
detect_earnings_acceleration,
interpret_earnings_score,
score_current_earnings,
)
from calculators.growth_calculator import (
calculate_annual_growth,
check_consistency,
interpret_growth_score,
score_annual_growth,
)
from calculators.leadership_calculator import (
_sort_prices_ascending,
calculate_leadership,
calculate_sector_relative_strength,
interpret_leadership,
score_leadership,
slice_period_return,
)
from calculators.market_calculator import (
calculate_ema,
calculate_market_direction,
interpret_market_score,
score_market_direction,
)
from calculators.new_highs_calculator import (
calculate_newness,
interpret_newness_score,
score_newness,
)
from calculators.supply_demand_calculator import (
calculate_supply_demand,
interpret_supply_demand,
score_supply_demand,
)
# ---------------------------------------------------------------- C component
def _quarters(eps_latest, eps_year_ago, rev_latest, rev_year_ago):
"""Five quarters, newest first; index 4 is the year-ago quarter."""
middle = [{"eps": 1.0, "revenue": 100.0} for _ in range(3)]
return [
{"date": "2024-06-30", "eps": eps_latest, "revenue": rev_latest},
*middle,
{"date": "2023-06-30", "eps": eps_year_ago, "revenue": rev_year_ago},
]
class TestEarningsCalculator:
def test_insufficient_quarters(self):
result = calculate_quarterly_growth(_quarters(1, 1, 1, 1)[:4])
assert result["score"] == 0
assert "at least 5 quarters" in result["error"]
assert result["latest_qtr_eps_growth"] is None
def test_exceptional_growth_scores_100(self):
result = calculate_quarterly_growth(_quarters(3.0, 1.0, 300.0, 100.0))
assert result["score"] == 100
assert result["latest_qtr_eps_growth"] == 200.0
assert result["latest_qtr_revenue_growth"] == 200.0
assert result["latest_qtr_date"] == "2024-06-30"
assert result["year_ago_qtr_date"] == "2023-06-30"
assert result["quality_warning"] is None
assert result["interpretation"].startswith("Exceptional")
def test_alternate_eps_field_names(self):
stmts = _quarters(None, None, 130.0, 100.0)
stmts[0].pop("eps")
stmts[4].pop("eps")
stmts[0]["epsdiluted"] = 1.4
stmts[4]["netIncomePerShare"] = 1.0
result = calculate_quarterly_growth(stmts)
assert result["latest_eps"] == 1.4
assert result["year_ago_eps"] == 1.0
assert result["latest_qtr_eps_growth"] == 40.0
assert result["score"] == 80
def test_missing_eps(self):
stmts = _quarters(1.0, 1.0, 100.0, 100.0)
stmts[0].pop("eps")
result = calculate_quarterly_growth(stmts)
assert result["score"] == 0
assert result["error"] == "EPS data missing or invalid"
assert result["year_ago_eps"] == 1.0
@pytest.mark.parametrize("latest_rev,year_ago_rev", [(None, 100.0), (100.0, None), (100.0, 0)])
def test_missing_or_zero_revenue(self, latest_rev, year_ago_rev):
result = calculate_quarterly_growth(_quarters(2.0, 1.0, latest_rev, year_ago_rev))
assert result["score"] == 0
assert result["error"] == "Revenue data missing or invalid"
assert result["interpretation"] == "Revenue data unavailable"
def test_negative_year_ago_eps_uses_abs_denominator(self):
result = calculate_quarterly_growth(_quarters(0.5, -0.5, 130.0, 100.0))
# (0.5 - -0.5) / 0.5 = +200%
assert result["latest_qtr_eps_growth"] == 200.0
assert result["score"] == 100
def test_zero_year_ago_eps_via_netincomepershare_turnaround(self):
# eps / epsdiluted absent, netIncomePerShare == 0 reaches the zero branch
stmts = _quarters(None, None, 130.0, 100.0)
stmts[0] = {"date": "2024-06-30", "netIncomePerShare": 0.4, "revenue": 130.0}
stmts[4] = {"date": "2023-06-30", "netIncomePerShare": 0, "revenue": 100.0}
result = calculate_quarterly_growth(stmts)
assert result["latest_qtr_eps_growth"] == 999.9
assert result["score"] == 100
def test_zero_year_ago_eps_non_positive_latest_gives_zero_growth(self):
stmts = _quarters(None, None, 130.0, 100.0)
stmts[0] = {"date": "2024-06-30", "netIncomePerShare": 0, "revenue": 130.0}
stmts[4] = {"date": "2023-06-30", "netIncomePerShare": 0, "revenue": 100.0}
result = calculate_quarterly_growth(stmts)
assert result["latest_qtr_eps_growth"] == 0
assert result["score"] == 0
def test_quality_warning_when_revenue_lags(self):
result = calculate_quarterly_growth(_quarters(1.5, 1.0, 105.0, 100.0))
# EPS +50%, revenue +5% < 25%
assert "buyback-driven" in result["quality_warning"]
assert result["score"] == 40
@pytest.mark.parametrize(
"eps,rev,expected",
[
(50, 25, 100),
(49.9, 25, 80),
(30, 15, 80),
(30, 14.9, 60),
(18, 10, 60),
(18, 9.9, 40),
(10, 0, 40),
(9.9, 100, 0),
(-5, -5, 0),
],
)
def test_score_current_earnings_boundaries(self, eps, rev, expected):
assert score_current_earnings(eps, rev) == expected
@pytest.mark.parametrize(
"score,prefix",
[
(100, "Exceptional"),
(80, "Strong"),
(60, "Acceptable"),
(40, "Below threshold"),
(0, "Weak"),
],
)
def test_interpret_earnings_score(self, score, prefix):
text = interpret_earnings_score(score, 12.3, 4.5)
assert text.startswith(prefix)
assert "12.3%" in text
def test_acceleration_insufficient_data(self):
result = detect_earnings_acceleration([{"eps": 1.0}] * 5)
assert result["trend"] == "unknown"
assert result["recent_growth"] is None
def _six(self, recent, recent_ya, prior, prior_ya):
return [
{"eps": recent},
{"eps": prior},
{"eps": 1.0},
{"eps": 1.0},
{"eps": recent_ya},
{"eps": prior_ya},
]
def test_acceleration_detected(self):
result = detect_earnings_acceleration(self._six(2.0, 1.0, 1.2, 1.0))
assert result["trend"] == "accelerating"
assert result["recent_growth"] == 100.0
assert result["prior_growth"] == 20.0
assert "accelerating" in result["interpretation"]
def test_deceleration_detected(self):
result = detect_earnings_acceleration(self._six(1.1, 1.0, 2.0, 1.0))
assert result["trend"] == "decelerating"
assert result["recent_growth"] == 10.0
assert result["prior_growth"] == 100.0
assert "Warning sign" in result["interpretation"]
def test_stable_trend_and_zero_year_ago(self):
result = detect_earnings_acceleration(self._six(1.2, 1.0, 1.2, 1.0))
assert result["trend"] == "stable"
zero = detect_earnings_acceleration(self._six(1.2, 0, 1.2, 0))
assert zero["recent_growth"] == 0
assert zero["prior_growth"] == 0
assert zero["trend"] == "stable"
# ---------------------------------------------------------------- A component
def _annual(eps_newest_first, revenues_newest_first=None):
revenues_newest_first = revenues_newest_first or [100.0] * len(eps_newest_first)
return [
{"date": f"{2024 - i}-12-31", "eps": e, "revenue": r}
for i, (e, r) in enumerate(zip(eps_newest_first, revenues_newest_first))
]
class TestGrowthCalculator:
def test_insufficient_years_is_neutral_50(self):
result = calculate_annual_growth(_annual([3, 2, 1]))
assert result["score"] == 50
assert result["stability"] == "unknown"
assert "at least 4 years" in result["error"]
def test_missing_eps(self):
stmts = _annual([8, 4, 2, 1])
stmts[2].pop("eps")
result = calculate_annual_growth(stmts)
assert result["score"] == 0
assert result["error"] == "Missing EPS data for year 2022-12-31"
@pytest.mark.parametrize("bad", [-1.0])
def test_non_positive_eps(self, bad):
stmts = _annual([8, 4, bad, 1])
result = calculate_annual_growth(stmts)
assert result["score"] == 0
assert "Negative or zero EPS in year 2022-12-31" in result["error"]
assert result["interpretation"] == "Negative EPS - not a CANSLIM candidate"
def test_doubling_each_year_with_matching_revenue(self):
result = calculate_annual_growth(
_annual([8, 4, 2, 1], [800.0, 400.0, 200.0, 100.0]),
)
assert result["eps_cagr_3yr"] == 100.0
assert result["revenue_cagr_3yr"] == 100.0
assert result["stability"] == "stable"
assert result["eps_values"] == [1, 2, 4, 8]
assert result["revenue_values"] == [100, 200, 400, 800]
assert result["years_analyzed"] == 4
assert result["score"] == 100 # 90 base + 10 stability, capped at 100
assert result["quality_warning"] is None
assert result["interpretation"].startswith("Exceptional")
def test_buyback_driven_growth_gets_penalty_and_warning(self):
result = calculate_annual_growth(_annual([8, 4, 2, 1])) # flat revenue
assert result["revenue_cagr_3yr"] == 0.0
assert "buyback-driven" in result["quality_warning"]
# 90 * 0.8 = 72 + 10 stability bonus
assert result["score"] == 82
def test_erratic_growth_and_zero_start_revenue(self):
# chronological EPS: 1.0, 3.0, 2.0, 2.0 -> a down year
stmts = _annual([2.0, 2.0, 3.0, 1.0], [0, 0, 0, 0])
result = calculate_annual_growth(stmts)
assert result["stability"] == "erratic"
assert result["revenue_cagr_3yr"] == 0
assert result["eps_cagr_3yr"] == pytest.approx(26.0, abs=0.1)
# base 50, revenue 0 < 13 -> int(40.0), no stability bonus
assert result["score"] == 40
def test_alternate_eps_key_epsdiluted(self):
stmts = [
{"date": f"{2024 - i}-12-31", "epsdiluted": e, "revenue": 100.0}
for i, e in enumerate([1.0, 1.0, 1.0, 1.0])
]
result = calculate_annual_growth(stmts)
assert result["eps_cagr_3yr"] == 0.0
assert result["score"] == 10 # base 0 (flat) + 10 stability
assert result["interpretation"].startswith("Weak")
@pytest.mark.parametrize(
"cagr,expected",
[(40, 90), (30, 70), (25, 50), (15, 30), (14.9, 0)],
)
def test_score_bases_with_matching_revenue_unstable(self, cagr, expected):
assert score_annual_growth(cagr, cagr, stable=False) == expected
def test_score_penalty_and_cap(self):
assert score_annual_growth(40, 10, stable=False) == 72
assert score_annual_growth(40, 40, stable=True) == 100
@pytest.mark.parametrize(
"score,prefix",
[
(95, "Exceptional"),
(75, "Strong"),
(55, "Acceptable"),
(35, "Below threshold"),
(10, "Weak"),
],
)
def test_interpret_growth_score(self, score, prefix):
assert interpret_growth_score(score, 33.3, True).startswith(prefix)
def test_check_consistency_insufficient(self):
result = check_consistency([{"eps": 1.0}])
assert result["down_years"] is None
assert result["interpretation"] == "Insufficient data"
def test_check_consistency_pattern(self):
# chronological: 1.0, 2.0, 1.0, 2.0, 4.0
stmts = [{"eps": 4.0}, {"eps": 2.0}, {"epsdiluted": 1.0}, {"eps": 2.0}, {"eps": 1.0}]
result = check_consistency(stmts)
assert result["growth_pattern"] == [100.0, -50.0, 100.0, 100.0]
assert result["down_years"] == 1
assert result["consecutive_growth_years"] == 2
assert result["interpretation"] == "2 consecutive years of growth, 1 down years"
# ---------------------------------------------------------------- N component
class TestNewHighsCalculator:
def test_missing_quote(self):
result = calculate_newness({})
assert result["score"] == 0
assert result["error"] == "Quote data missing"
def test_missing_price_or_high(self):
result = calculate_newness({"price": 100})
assert result["score"] == 0
assert result["error"] == "Price or 52-week high data missing"
def test_breakout_near_high_scores_100(self):
quote = {
"price": 99.8,
"yearHigh": 100.0,
"yearLow": 50.0,
"volume": 1_500_000,
"avgVolume": 1_000_000,
}
result = calculate_newness(quote)
assert result["breakout_detected"] is True
assert result["distance_from_high_pct"] == -0.2
assert result["volume_ratio"] == 1.5
assert result["score"] == 100
assert result["interpretation"].startswith("Exceptional")
assert "with volume breakout" in result["interpretation"]
assert result["error"] is None
def test_no_volume_info_no_breakout(self):
result = calculate_newness({"price": 90.0, "yearHigh": 100.0})
assert result["breakout_detected"] is False
assert result["volume_ratio"] is None
assert result["score"] == 60 # within 15% of high
assert result["interpretation"].startswith("Acceptable")
def test_volume_below_threshold_is_not_breakout(self):
quote = {"price": 100.0, "yearHigh": 100.0, "volume": 1_400_000, "avgVolume": 1_000_000}
assert calculate_newness(quote)["breakout_detected"] is False
@pytest.mark.parametrize(
"dist,breakout,expected",
[
(-5, True, 100),
(-7, True, 80),
(-20, True, 60),
(-15, False, 60),
(-20, False, 40),
(-25, False, 40),
(-26, False, 20),
],
)
def test_score_newness(self, dist, breakout, expected):
assert score_newness(dist, breakout) == expected
@pytest.mark.parametrize(
"score,prefix",
[
(100, "Exceptional"),
(80, "Strong"),
(60, "Acceptable"),
(40, "Weak"),
(20, "Poor"),
],
)
def test_interpret_newness(self, score, prefix):
assert interpret_newness_score(score, -3.0, False).startswith(prefix)
# ---------------------------------------------------------------- S component
def _volume_history(up_vol, down_vol, days=60, alternate=True):
"""Newest-first series alternating up/down closes with given volumes."""
# Build chronologically then reverse. Close moves +1 / -1 alternately.
chrono = []
close = 100.0
for i in range(days):
if i == 0:
vol = up_vol
elif alternate and i % 2 == 1:
close += 1.0
vol = up_vol
else:
close -= 1.0
vol = down_vol
chrono.append({"date": f"d{i:03d}", "close": close, "volume": vol})
return {"historical": chrono[::-1]}
class TestSupplyDemandCalculator:
def test_no_data(self):
result = calculate_supply_demand(None)
assert result["score"] == 0
assert result["error"] == "No historical price data provided"
assert calculate_supply_demand({"x": 1})["score"] == 0
def test_insufficient_days(self):
result = calculate_supply_demand({"historical": [{"close": 1, "volume": 1}] * 10})
assert result["score"] == 0
assert "need 60+ days, got 10" in result["error"]
assert "Less than 60 days" in result["quality_warning"]
def test_too_few_down_days(self):
# monotonically rising closes -> no down days
rising = [{"close": 100 - i, "volume": 10} for i in range(60)]
result = calculate_supply_demand({"historical": rising})
assert result["score"] == 0
assert result["error"] == "Insufficient up/down days for analysis"
assert result["up_days_count"] == 59
assert result["down_days_count"] == 0
assert "up: 59, down: 0" in result["quality_warning"]
def test_strong_accumulation(self):
result = calculate_supply_demand(_volume_history(2000, 1000))
assert result["up_down_ratio"] == 2.0
assert result["score"] == 100
assert result["accumulation_detected"] is True
assert result["avg_up_volume"] == 2000
assert result["avg_down_volume"] == 1000
assert result["up_days_count"] + result["down_days_count"] == 59
assert result["interpretation"].startswith("Strong Accumulation")
def test_zero_down_volume_caps_ratio(self):
result = calculate_supply_demand(_volume_history(1000, 0))
assert result["up_down_ratio"] == 5.0
assert result["score"] == 100
def test_distribution(self):
result = calculate_supply_demand(_volume_history(1000, 2500))
assert result["up_down_ratio"] == pytest.approx(0.4)
assert result["score"] == 0
assert result["accumulation_detected"] is False
assert result["interpretation"].startswith("Strong Distribution")
def test_incomplete_rows_are_skipped(self):
data = _volume_history(2000, 1000)
data["historical"][5] = {"date": "x", "close": None, "volume": 5}
data["historical"][6] = {"date": "y", "close": 50, "volume": None}
result = calculate_supply_demand(data)
assert result["up_days_count"] + result["down_days_count"] < 59
@pytest.mark.parametrize(
"ratio,expected",
[(2.0, 100), (1.5, 80), (1.0, 60), (0.7, 40), (0.5, 20), (0.49, 0)],
)
def test_score_boundaries(self, ratio, expected):
assert score_supply_demand(ratio) == expected
@pytest.mark.parametrize(
"ratio,prefix",
[
(2.5, "Strong Accumulation"),
(1.6, "Accumulation"),
(1.1, "Neutral/Weak Accumulation"),
(0.8, "Neutral/Weak Distribution"),
(0.6, "Distribution"),
(0.1, "Strong Distribution"),
],
)
def test_interpret_supply_demand(self, ratio, prefix):
text = interpret_supply_demand(ratio, ratio >= 1.5)
assert text.startswith(prefix)
assert f"{ratio:.2f}x" in text
# ---------------------------------------------------------------- L component
def _series(start, end, days, dates_desc=False):
step = (end - start) / (days - 1)
rows = [
{"date": f"2024-{1 + i // 28:02d}-{1 + i % 28:02d}", "close": start + step * i}
for i in range(days)
]
# 2024-xx dates stop being valid after 12 months, but they only need to sort
return rows[::-1] if dates_desc else rows
class TestLeadershipCalculator:
def test_sort_prices_ascending_handles_empty_and_type_error(self):
assert _sort_prices_ascending([]) == []
mixed = [{"date": 1}, {"date": "2024"}]
assert _sort_prices_ascending(mixed) == mixed
def test_slice_period_return_non_numeric_close(self):
prices = [{"date": "a", "close": "x"}, {"date": "b", "close": 2.0}]
assert slice_period_return(prices, 2) is None
def test_slice_period_return_bad_entries(self):
assert slice_period_return([{"date": "a", "close": 1}], 0) is None
# Oddity pinned as-is: only TypeError is caught by the sort helper, so non-dict
# rows raise AttributeError. FMP always returns dict rows, so this is unreachable.
with pytest.raises(AttributeError):
slice_period_return(["not-a-dict", "also-bad"], 2)
def test_insufficient_history(self):
result = calculate_leadership(_series(100, 110, 10))
assert result["score"] == 0
assert "need 50+ days" in result["error"]
assert result["rs_rating"] == "Weak"
assert result["missing_periods"] == ["3m", "6m", "12m"]
def test_invalid_start_price(self):
prices = _series(0, 10, 60)
result = calculate_leadership(prices)
assert result["error"] == "Invalid start price (zero or negative)"
assert result["score"] == 0
def test_price_calculation_error(self):
prices = [{"date": f"d{i:03d}"} for i in range(60)]
prices[0]["close"] = None
result = calculate_leadership(prices)
assert result["score"] == 0
assert result["error"].startswith("Price calculation error")
def test_legacy_precomputed_benchmark_path(self):
prices = _series(100, 160, 60) # +60%
result = calculate_leadership(prices, sp500_performance=10.0)
assert result["stock_52w_performance"] == 60.0
assert result["relative_performance"] == 50.0
assert result["score"] == 100
assert result["rs_rank_estimate"] == 99
assert result["rs_rating"] == "Market Leader"
assert result["rs_3m_return"] is None
assert result["available_periods"] == []
assert result["benchmark_52w_performance"] == 10.0
assert result["interpretation"].startswith("Market Leader - 60-day return +60.0%")
def test_no_benchmark_uses_absolute_with_penalty(self):
prices = _series(100, 130, 80) # +30% over 80 bars: no 3m window (63)? yes 63<=80
result = calculate_leadership(prices)
assert "unavailable" in result["quality_warning"]
assert result["sp500_52w_performance"] is None
assert result["available_periods"] == ["3m"]
# multi-period absolute: 3m return is smaller than 30%
assert result["weighted_relative_performance"] is None
assert result["weighted_stock_performance"] == result["rs_3m_return"]
assert "(absolute performance)" in result["interpretation"]
def test_no_benchmark_short_history_uses_legacy_absolute(self):
prices = _series(100, 130, 55) # fewer than 63 bars -> no multi-period
result = calculate_leadership(prices)
assert result["available_periods"] == []
assert result["relative_performance"] == 30.0
# +30% -> 95 base, no benchmark -> int(95 * 0.8)
assert result["score"] == 76
assert result["rs_rank_estimate"] == int(95 * 0.9)
def test_insufficient_benchmark_history(self):
prices = _series(100, 130, 55)
bench = _series(100, 105, 20)
result = calculate_leadership(prices, sp500_historical=bench)
assert "data insufficient" in result["quality_warning"]
assert result["score"] == 76
def test_short_history_with_benchmark_uses_legacy_relative(self):
prices = _series(100, 130, 55)
bench = _series(100, 110, 55)
result = calculate_leadership(prices, sp500_historical=bench)
assert result["available_periods"] == []
assert result["sp500_52w_performance"] == 10.0
assert result["relative_performance"] == 20.0
assert result["score"] == 90
assert result["quality_warning"] is None
assert "+20.0% vs benchmark" in result["interpretation"]
def test_benchmark_start_price_zero_leaves_legacy_benchmark_none(self):
prices = _series(100, 130, 55)
bench = _series(0, 10, 55)
result = calculate_leadership(prices, sp500_historical=bench)
assert result["sp500_52w_performance"] is None
assert result["score"] == 76
def test_benchmark_with_missing_close_flags_warning(self):
prices = _series(100, 130, 55)
bench = [{"date": f"d{i:03d}", "close": None} for i in range(55)]
result = calculate_leadership(prices, sp500_historical=bench)
assert result["quality_warning"] == "Benchmark performance calculation failed"
def test_full_multi_period_relative_path(self):
prices = _series(100, 200, 260, dates_desc=True)
bench = _series(100, 110, 260)
result = calculate_leadership(prices, sp500_historical=bench, rs_benchmark="QQQ")
assert result["available_periods"] == ["3m", "6m", "12m"]
assert result["rs_benchmark"] == "QQQ"
assert result["weighted_relative_performance"] > 0
assert result["rs_12m_return"] > result["benchmark_12m_return"]
assert result["rs_benchmark_relative_return"] == result["rel_12m"]
assert result["score"] == result["rs_component_score"]
assert result["rs_rank_percentile"] == result["rs_rank_estimate"]
assert result["stock_52w_performance"] == 100.0
assert result["sp500_52w_performance"] == 10.0
assert result["interpretation"].startswith("Market Leader - 52-week return")
@pytest.mark.parametrize(
"rel,score,rank",
[
(50, 100, 99),
(30, 95, 95),
(20, 90, 90),
(10, 80, 80),
(5, 70, 70),
(0, 60, 60),
(-5, 50, 50),
(-10, 40, 40),
(-20, 20, 25),
(-20.1, 0, 10),
],
)
def test_score_leadership_bands(self, rel, score, rank):
assert score_leadership(rel, True) == (score, rank)
def test_score_leadership_no_benchmark_penalty(self):
assert score_leadership(50, False) == (80, 89)
@pytest.mark.parametrize(
"days,period",
[(260, "52-week"), (200, "9-month"), (100, "quarterly"), (70, "70-day")],
)
def test_interpret_leadership_period_labels(self, days, period):
text = interpret_leadership(60, 5.0, 3.0, 2.0, days)
assert f"{period} return +5.0%" in text
@pytest.mark.parametrize(
"score,rating",
[
(95, "Market Leader"),
(85, "Strong Performer"),
(65, "Above Average"),
(45, "Average"),
(25, "Laggard"),
(5, "Weak"),
],
)
def test_interpret_leadership_ratings(self, score, rating):
text = interpret_leadership(score, -4.0, -1.0, -3.0, 260)
assert text.startswith(rating)
assert "-4.0%" in text
assert "-3.0% vs benchmark" in text
def test_sector_relative_strength(self):
empty = calculate_sector_relative_strength(10.0, [])
assert empty["sector_rank"] is None
assert empty["is_sector_leader"] is False
result = calculate_sector_relative_strength(50.0, [10.0, 20.0, 30.0, 40.0])
assert result["sector_rank"] == 1
assert result["sector_total"] == 5
assert result["sector_percentile"] == 80.0
assert result["is_sector_leader"] is True
low = calculate_sector_relative_strength(5.0, [10.0, 20.0, 30.0, 40.0])
assert low["sector_rank"] == 5
assert low["sector_percentile"] == 0.0
assert low["is_sector_leader"] is False
# ---------------------------------------------------------------- M component
def _index_history(n, close=100.0):
return [{"date": f"d{i:03d}", "close": close} for i in range(n)]
class TestMarketCalculator:
def test_missing_quote(self):
result = calculate_market_direction({})
assert result["score"] == 50
assert result["trend"] == "unknown"
assert result["error"] == "S&P 500 quote data missing"
def test_missing_price(self):
result = calculate_market_direction({"symbol": "^GSPC"})
assert result["error"] == "S&P 500 price missing"
assert result["score"] == 50
def test_insufficient_history_does_not_fabricate_trend(self):
result = calculate_market_direction({"price": 5000}, _index_history(30))
assert result["score"] == 50
assert result["trend"] == "unknown"
assert result["sp500_price"] == 5000
assert result["sp500_ema_50"] is None
assert "Insufficient S&P 500 history" in result["error"]
def test_none_closes_are_filtered_before_length_check(self):
history = _index_history(60)
for row in history[:20]:
row["close"] = None
result = calculate_market_direction({"price": 100}, history)
assert result["trend"] == "unknown"
@pytest.mark.parametrize(
"price,vix,trend,score",
[
(103.0, 12.0, "strong_uptrend", 100),
(103.0, 20.0, "strong_uptrend", 90),
(101.0, 12.0, "uptrend", 80),
(101.0, None, "uptrend", 70),
(99.0, 20.0, "choppy", 40),
(97.0, 20.0, "downtrend", 20),
(90.0, 20.0, "bear_market", 0),
(103.0, 35.0, "strong_uptrend", 0),
],
)
def test_trend_and_score_matrix(self, price, vix, trend, score):
vix_quote = {"price": vix} if vix is not None else None
result = calculate_market_direction({"price": price}, _index_history(60), vix_quote)
assert result["trend"] == trend
assert result["score"] == score
assert result["sp500_ema_50"] == pytest.approx(100.0)
assert result["vix_level"] == vix
assert result["error"] is None
def test_bear_market_warning_and_distance(self):
result = calculate_market_direction({"price": 90.0}, _index_history(60), {"price": 25.0})
assert result["distance_from_ema_pct"] == -10.0
assert "BEAR MARKET" in result["warning"]
assert result["interpretation"].startswith("Bear market")
assert "VIX 25.0" in result["interpretation"]
def test_no_warning_in_uptrend(self):
result = calculate_market_direction({"price": 101.0}, _index_history(60))
assert result["warning"] is None
assert result["distance_from_ema_pct"] == 1.0
def test_calculate_ema_short_input_is_simple_average(self):
assert calculate_ema([1.0, 2.0, 3.0], period=50) == 2.0
def test_calculate_ema_weights_newest_first_input(self):
# newest first: [20, 10, 10]; oldest-first SMA of first 2 = 10, then 20
# k = 2/3 -> 20 * 2/3 + 10 * 1/3
assert calculate_ema([20.0, 10.0, 10.0], period=2) == pytest.approx(50 / 3)
@pytest.mark.parametrize(
"score,prefix",
[
(100, "Strong bull market"),
(70, "Bull market"),
(50, "Early uptrend"),
(40, "Choppy/neutral"),
(20, "Downtrend forming"),
(0, "Bear market"),
],
)
def test_interpret_market_score(self, score, prefix):
text = interpret_market_score(score, "x", 1.25, 14.0)
assert text.startswith(prefix)
assert "+1.2% from 50-EMA" in text
assert ", VIX 14.0" in text
def test_score_market_direction_direct(self):
assert score_market_direction("uptrend", 14.9) == 80
assert score_market_direction("downtrend", 31) == 0
assert score_market_direction("strong_uptrend", 15) == 90
# ---------------------------------------------------------------- I component
def _holders(n, shares=1000, names=None):
names = names or [f"FUND {i}" for i in range(n)]
return [{"holder": nm, "shares": shares} for nm in names]
class TestInstitutionalCalculator:
def test_sweet_spot_from_list_and_profile(self):
holders = _holders(60, shares=1000) # 60,000 shares
profile = {"sharesOutstanding": 150_000} # 40%
result = inst.calculate_institutional_sponsorship(holders, profile, "TEST", False)
assert result["num_holders"] == 60
assert result["ownership_pct"] == pytest.approx(40.0)
assert result["total_shares_held"] == 60_000
assert result["score"] == 100
assert result["data_source"] == "FMP"
assert result["superinvestors"] == []
assert "sweet spot" in result["interpretation"]
def test_shares_outstanding_derived_from_market_cap(self):
holders = _holders(60, shares=1000)
profile = {"marketCap": 15_000_000, "price": 100.0} # 150,000 shares
result = inst.calculate_institutional_sponsorship(
holders, profile, use_finviz_fallback=False
)
assert result["shares_outstanding"] == pytest.approx(150_000)
assert result["ownership_pct"] == pytest.approx(40.0)
def test_legacy_mktcap_field(self):
holders = _holders(60, shares=1000)
profile = {"mktCap": 15_000_000, "price": 100.0}
result = inst.calculate_institutional_sponsorship(
holders, profile, use_finviz_fallback=False
)
assert result["ownership_pct"] == pytest.approx(40.0)
def test_no_shares_outstanding_warns_and_scores_by_count(self):
result = inst.calculate_institutional_sponsorship(
_holders(60), {"price": 0}, use_finviz_fallback=False
)
assert result["ownership_pct"] is None
assert "Shares outstanding unavailable from FMP." in result["quality_warning"]
assert "Score reduced by 50%" in result["quality_warning"]
assert result["score"] == 50
def test_missing_profile_warning(self):
result = inst.calculate_institutional_sponsorship(
_holders(35), None, use_finviz_fallback=False
)
assert result["quality_warning"].startswith("Company profile not provided.")
assert result["score"] == 40
assert "ownership % unavailable" in result["interpretation"]
def test_stable_aggregate_dict_with_superinvestor(self):
agg = {
"num_holders": 80,
"ownership_pct": 75.0,
"top_holders": [
{"holder": "Berkshire Hathaway Inc", "shares": 10},
{"holder": "Pershing Square Capital", "shares": 5},
{"holder": "Scion Asset Management LLC", "shares": 5},
{"holder": None, "shares": None},
],
}
result = inst.calculate_institutional_sponsorship(agg, None, use_finviz_fallback=False)
assert result["superinvestor_present"] is True
assert result["superinvestors"] == [
"Berkshire Hathaway Inc",
"Pershing Square Capital",
"Scion Asset Management LLC",
]
assert result["ownership_pct"] == 75.0
assert result["total_shares_held"] == 20
assert result["score"] == 100 # 90 base + 10 superinvestor bonus
assert "+1 more" in result["interpretation"]
def test_missing_holders_everywhere_scores_zero(self):
result = inst.calculate_institutional_sponsorship(None, {}, "X", False)
assert result["score"] == 0
assert result["quality_warning"] == (
"Institutional holder data unavailable from FMP and Finviz. Score set to 0."
)
def test_empty_list_without_profile_scores_zero(self):
result = inst.calculate_institutional_sponsorship([], None, use_finviz_fallback=False)
assert result["score"] == 0
assert result["num_holders"] == 0
def test_finviz_fallback_success(self, monkeypatch):
class FakeFinviz:
def __init__(self, rate_limit_seconds=1.0):
pass
def get_institutional_ownership(self, symbol):
assert symbol == "ABC"
return {"inst_own_pct": 45.5}
monkeypatch.setattr(inst, "FinvizStockClient", FakeFinviz, raising=False)
monkeypatch.setattr(inst, "FINVIZ_AVAILABLE", True)
result = inst.calculate_institutional_sponsorship(_holders(60), {"price": 0}, "ABC", True)
assert result["ownership_pct"] == 45.5
assert result["data_source"] == "Finviz"
assert result["score"] == 100
assert "Using Finviz institutional ownership data (45.5%)" in result["quality_warning"]
def test_finviz_fallback_returns_nothing(self, monkeypatch):
class FakeFinviz:
def __init__(self, rate_limit_seconds=1.0):
pass
def get_institutional_ownership(self, symbol):
return {"inst_own_pct": None}
monkeypatch.setattr(inst, "FinvizStockClient", FakeFinviz, raising=False)
monkeypatch.setattr(inst, "FINVIZ_AVAILABLE", True)
result = inst.calculate_institutional_sponsorship(_holders(60), {"price": 0}, "ABC", True)
assert result["ownership_pct"] is None
assert "Finviz fallback also unavailable" in result["quality_warning"]
assert result["data_source"] == "FMP"
def test_finviz_fallback_raises(self, monkeypatch, capsys):
class FakeFinviz:
def __init__(self, rate_limit_seconds=1.0):
pass
def get_institutional_ownership(self, symbol):
raise RuntimeError("boom")
monkeypatch.setattr(inst, "FinvizStockClient", FakeFinviz, raising=False)
monkeypatch.setattr(inst, "FINVIZ_AVAILABLE", True)
result = inst.calculate_institutional_sponsorship(_holders(60), {"price": 0}, "ABC", True)
assert "Finviz fallback failed" in result["quality_warning"]
assert "boom" in capsys.readouterr().err
@pytest.mark.parametrize(
"holders,sp,expected",
[
(60, False, 50),
(35, False, 40),
(120, False, 35),
(200, False, 20),
(5, False, 10),
(60, True, 65),
(200, True, 35),
],
)
def test_score_without_ownership(self, holders, sp, expected):
assert inst.score_institutional_sponsorship(holders, None, sp, None) == expected
def test_score_without_ownership_caps_at_70(self):
assert inst.score_institutional_sponsorship(60, None, True, None) == 65
@pytest.mark.parametrize(
"holders,pct,sp,expected",
[
(75, 45, False, 100),
(200, 45, True, 50 + 10), # superinvestor but outside 30-150 -> default 50 +10
(40, 30, False, 80),
(120, 50, False, 80),
(25, 40, False, 60),
(60, 25, False, 60),
(10, 5, False, 20),
(10, 95, False, 20),
(10, 15, False, 40),
(10, 85, False, 40),
(10, 50, False, 50),
(40, 30, True, 100), # 90 base + 10 superinvestor bonus
],
)
def test_score_with_ownership(self, holders, pct, sp, expected):
assert inst.score_institutional_sponsorship(holders, pct, sp, None) == expected
@pytest.mark.parametrize(
"holders,msg",
[
(75, "sweet spot"),
(40, "good, but could grow"),
(120, "getting crowded"),
(200, "overcrowded"),
(10, "underowned"),
],
)
def test_interpret_holder_messages(self, holders, msg):
text = inst.interpret_institutional_sponsorship(holders, None, False, [])
assert msg in text
@pytest.mark.parametrize(
"pct,msg",
[
(45.0, "(ideal range)"),
(25.0, "(acceptable)"),
(70.0, "(acceptable)"),
(10.0, "(underowned, potential upside)"),
(95.0, "(overcrowded, vulnerable)"),
],
)
def test_interpret_ownership_messages(self, pct, msg):
text = inst.interpret_institutional_sponsorship(60, pct, False, [])
assert msg in text
def test_interpret_superinvestor_names_truncated(self):
text = inst.interpret_institutional_sponsorship(60, 40.0, True, ["A", "B", "C", "D"])
assert "Superinvestors: A, B +2 more" in text
def test_zero_eps_in_annual_growth_is_reported_as_missing():
"""Pin: a literal 0 EPS is falsy, so it is reported as missing rather than non-positive."""
stmts = _annual([8, 4, 0, 1])
result = calculate_annual_growth(stmts)
assert result["score"] == 0
assert result["error"] == "Missing EPS data for year 2022-12-31"
scripts/tests/test_canslim_coverage.py
#!/usr/bin/env python3
"""Coverage tests for the CANSLIM screening pipeline (Issue #293).
Covers the FMP client (fake HTTP session), the Finviz client (fake session),
the institutional endpoint probe, scorer, report generator and the
screen_canslim CLI/main path. No network access and no dependence on the
wall clock: dates are injected or patched.
"""
import json
import os
import runpy
import sys
import types
from datetime import date, datetime, timedelta, timezone
from unittest.mock import Mock
import check_institutional_endpoint as cie
import finviz_stock_client as fsc
import fmp_client
import pytest
import report_generator as rg
import requests
import scorer
import screen_canslim as sc
from fmp_client import FMPClient
TEST_KEY = "unit-test-key-123" # pragma: allowlist secret
# ---------------------------------------------------------------- helpers
class FakeResponse:
def __init__(self, status_code=200, json_data=None, text=""):
self.status_code = status_code
self._json = json_data
self.text = text
def json(self):
return self._json
class FakeSession:
"""Routes GET requests through ``handler(url, params)``."""
def __init__(self, handler):
self.handler = handler
self.calls = []
self.headers = {}
def get(self, url, params=None, timeout=30):
self.calls.append((url, dict(params or {})))
result = self.handler(url, dict(params or {}))
if isinstance(result, Exception):
raise result
return result
def urls(self):
return [c[0] for c in self.calls]
@pytest.fixture
def sleeps(monkeypatch):
"""Freeze fmp_client's clock at t=1000 and record sleeps instead of waiting."""
recorded = []
fake_time = types.SimpleNamespace(time=lambda: 1000.0, sleep=recorded.append)
monkeypatch.setattr(fmp_client, "time", fake_time)
return recorded
@pytest.fixture
def make_client(monkeypatch, sleeps):
monkeypatch.delenv("FMP_API_KEY", raising=False)
def _make(handler, key=TEST_KEY):
client = FMPClient(api_key=key)
client.session = FakeSession(handler)
return client
return _make
STABLE = "https://financialmodelingprep.com/stable"
V3 = "https://financialmodelingprep.com/api/v3"
def ok(data):
return FakeResponse(200, data)
def fail(status=500, text="boom"):
return FakeResponse(status, None, text)
# ---------------------------------------------------------------- FMPClient basics
class TestFMPClientInit:
def test_missing_key_raises(self, monkeypatch):
monkeypatch.delenv("FMP_API_KEY", raising=False)
with pytest.raises(ValueError, match="FMP API key required"):
FMPClient()
def test_whitespace_only_key_raises(self, monkeypatch):
monkeypatch.setenv("FMP_API_KEY", " ")
with pytest.raises(ValueError):
FMPClient()
def test_env_key_is_stripped_and_sent_as_header(self, monkeypatch):
monkeypatch.setenv("FMP_API_KEY", f" {TEST_KEY}\n")
client = FMPClient()
assert client.api_key == TEST_KEY
assert client.session.headers["apikey"] == TEST_KEY
def test_explicit_key_wins_over_env(self, monkeypatch):
monkeypatch.setenv("FMP_API_KEY", "env-key") # pragma: allowlist secret
assert FMPClient(api_key="arg-key").api_key == "arg-key" # pragma: allowlist secret
class TestRateLimitedGet:
def test_success_returns_json_and_resets_retry_count(self, make_client):
client = make_client(lambda url, p: ok([{"a": 1}]))
client.retry_count = 1
assert client._rate_limited_get("http://x/y", {"q": 1}) == [{"a": 1}]
assert client.retry_count == 0
assert client.session.calls == [("http://x/y", {"q": 1})]
def test_second_call_waits_for_rate_limit(self, make_client, sleeps):
client = make_client(lambda url, p: ok({"ok": True}))
client._rate_limited_get("http://x")
assert sleeps == []
client._rate_limited_get("http://x")
assert sleeps == [pytest.approx(FMPClient.RATE_LIMIT_DELAY)]
def test_429_then_success_waits_60s(self, make_client, sleeps, capsys):
responses = [fail(429, "slow down"), ok({"v": 1})]
client = make_client(lambda url, p: responses.pop(0))
assert client._rate_limited_get("http://x") == {"v": 1}
assert 60 in sleeps
assert "Waiting 60 seconds" in capsys.readouterr().err
assert client.rate_limit_reached is False
def test_repeated_429_sets_rate_limit_flag_and_short_circuits(self, make_client, capsys):
client = make_client(lambda url, p: fail(429))
assert client._rate_limited_get("http://x") is None
assert client.rate_limit_reached is True
assert "Daily API rate limit reached" in capsys.readouterr().err
n_calls = len(client.session.calls)
assert client._rate_limited_get("http://x") is None
assert len(client.session.calls) == n_calls # no further HTTP once flagged
def test_http_error_prints_redacted_message(self, make_client, capsys):
client = make_client(lambda url, p: fail(403, f"bad key {TEST_KEY} apikey=zzz"))
assert client._rate_limited_get("http://x") is None
err = capsys.readouterr().err
assert "API request failed: 403" in err
assert TEST_KEY not in err
assert "***REDACTED***" in err
def test_quiet_suppresses_http_error(self, make_client, capsys):
client = make_client(lambda url, p: fail(404))
assert client._rate_limited_get("http://x", quiet=True) is None
assert capsys.readouterr().err == ""
def test_request_exception_is_swallowed_and_redacted(self, make_client, capsys):
exc = requests.exceptions.ConnectionError(f"https://h/p?apikey={TEST_KEY}&x=1")
client = make_client(lambda url, p: exc)
assert client._rate_limited_get("http://x") is None
err = capsys.readouterr().err
assert "Request exception" in err
assert TEST_KEY not in err
class TestRedaction:
def test_redact_key_masks_query_params(self):
assert fmp_client._redact_key("u?apikey=SECRET&x=1") == "u?apikey=REDACTED&x=1"
assert fmp_client._redact_key("u?x=1&API_KEY=SECRET") == "u?x=1&API_KEY=REDACTED"
def test_redact_secret_masks_raw_and_url_encoded_key(self, make_client):
key = "ab/cd+ef gh" # pragma: allowlist secret
client = make_client(lambda url, p: ok({}), key=key)
text = "raw ab/cd+ef gh enc ab%2Fcd%2Bef%20gh plus ab%2fcd%2bef+gh"
out = client._redact_secret(text)
assert "ab/cd" not in out
assert "%2F" not in out and "%2f" not in out
assert out.count("***REDACTED***") == 3
class TestEtToday:
def test_explicit_aware_now_converted_to_new_york(self):
# 03:00 UTC on 2025-07-01 is still 2025-06-30 in New York (EDT, UTC-4)
now = datetime(2025, 7, 1, 3, 0, tzinfo=timezone.utc)
assert fmp_client._et_today(now) == date(2025, 6, 30)
def test_naive_now_rejected(self):
with pytest.raises(ValueError, match="timezone-aware"):
fmp_client._et_today(datetime(2025, 7, 1, 3, 0))
def test_missing_tzdata_raises_runtime_error(self, monkeypatch):
def boom(name):
raise fmp_client.ZoneInfoNotFoundError(name)
monkeypatch.setattr(fmp_client, "ZoneInfo", boom)
with pytest.raises(RuntimeError, match="tzdata"):
fmp_client._et_today()
def test_default_uses_current_instant_in_new_york(self, monkeypatch):
class FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2025, 1, 2, 1, 0, tzinfo=timezone.utc).astimezone(tz)
monkeypatch.setattr(fmp_client, "datetime", FixedDatetime)
# 01:00 UTC on Jan 2 is Jan 1, 20:00 in New York (EST)
assert fmp_client._et_today() == date(2025, 1, 1)
class TestUrlBuilders:
def test_stable_quote_url_sets_symbol(self):
assert fmp_client._stable_quote_url("b", "^GSPC", {}) == ("b", {"symbol": "^GSPC"})
def test_v3_urls_use_path_style(self):
assert fmp_client._v3_quote_url("b", "AAPL", {"x": 1}) == ("b/AAPL", {"x": 1})
assert fmp_client._v3_hist_url("b", "AAPL", {}) == ("b/AAPL", {})
def test_stable_hist_url_converts_timeseries_to_date_range(self, monkeypatch):
monkeypatch.setattr(fmp_client, "_et_today", lambda now=None: date(2025, 6, 30))
url, params = fmp_client._stable_hist_url("b", "SPY", {"timeseries": 10})
assert url == "b"
assert params == {"symbol": "SPY", "from": "2025-06-10", "to": "2025-06-30"}
def test_stable_hist_url_without_timeseries_has_no_range(self):
_, params = fmp_client._stable_hist_url("b", "SPY", {})
assert params == {"symbol": "SPY"}
class TestNormalizeEodFlatList:
def test_non_list_passthrough(self):
payload = {"historical": []}
assert fmp_client._normalize_eod_flat_list(payload, "SPY") is payload
def test_empty_list_returns_none(self):
assert fmp_client._normalize_eod_flat_list([], "SPY") is None
def test_filters_symbol_strips_symbol_key_and_limits(self):
rows = [
{"symbol": "SPY", "date": "d3", "close": 3},
{"symbol": "QQQ", "date": "d2x", "close": 9},
{"date": "d2", "close": 2}, # missing symbol -> belongs to request
"junk",
{"symbol": "SPY", "date": "d1", "close": 1},
]
out = fmp_client._normalize_eod_flat_list(rows, "SPY", limit=2)
assert out == {
"symbol": "SPY",
"historical": [{"date": "d3", "close": 3}, {"date": "d2", "close": 2}],
}
def test_dash_and_dot_symbols_match(self):
rows = [{"symbol": "BRK.B", "date": "d", "close": 1}]
out = fmp_client._normalize_eod_flat_list(rows, "BRK-B")
assert out["symbol"] == "BRK.B"
assert out["historical"] == [{"date": "d", "close": 1}]
def test_no_matching_rows_returns_none(self):
assert fmp_client._normalize_eod_flat_list([{"symbol": "QQQ"}], "SPY") is None
# ---------------------------------------------------------------- FMPClient fallback
class TestRequestWithFallback:
def test_quote_wrong_symbol_on_stable_falls_back_to_v3(self, make_client):
def handler(url, params):
if url.startswith(STABLE):
return ok([{"symbol": "MSFT", "price": 1}])
return ok([{"symbol": "AAPL", "price": 2}])
client = make_client(handler)
assert client.get_quote("AAPL") == [{"symbol": "AAPL", "price": 2}]
assert client._endpoint_failures[f"{STABLE}/quote"] == 1
assert client.session.calls[1][0] == f"{V3}/quote/AAPL"
def test_quote_is_cached(self, make_client):
client = make_client(lambda url, p: ok([{"symbol": "AAPL", "price": 2}]))
client.get_quote("AAPL")
client.get_quote("AAPL")
assert len(client.session.calls) == 1
def test_batch_quote_skips_symbol_check(self, make_client):
data = [{"symbol": "A", "price": 1}, {"symbol": "B", "price": 2}]
client = make_client(lambda url, p: ok(data))
assert client.get_quote("A,B") == data
def test_non_list_quote_payload_is_invalid(self, make_client):
client = make_client(lambda url, p: ok({"error": "nope"}))
assert client.get_quote("AAPL") is None
def test_all_endpoints_fail_returns_none_and_is_not_cached(self, make_client):
client = make_client(lambda url, p: fail(500))
assert client.get_quote("AAPL") is None
assert "quote_AAPL" not in client.cache
def test_circuit_breaker_disables_failing_endpoint_after_three_failures(self, make_client):
def handler(url, params):
if url.startswith(STABLE):
return fail(500)
return ok([{"symbol": params_symbol(url), "price": 1}])
def params_symbol(url):
return url.rsplit("/", 1)[1]
client = make_client(handler)
for sym in ("AAA", "BBB", "CCC"):
assert client.get_quote(sym)[0]["symbol"] == sym
assert f"{STABLE}/quote" in client._disabled_endpoints
before = len(client.session.calls)
assert client.get_quote("DDD")[0]["symbol"] == "DDD"
new_urls = [c[0] for c in client.session.calls[before:]]
assert new_urls == [f"{V3}/quote/DDD"]
def test_historical_stable_flat_list_normalized_and_truncated(self, make_client, monkeypatch):
monkeypatch.setattr(fmp_client, "_et_today", lambda now=None: date(2025, 6, 30))
rows = [{"symbol": "SPY", "date": f"d{i}", "close": i} for i in range(5)]
client = make_client(lambda url, p: ok(rows))
out = client.get_historical_prices("SPY", days=2)
assert out["symbol"] == "SPY"
assert [r["date"] for r in out["historical"]] == ["d0", "d1"]
url, params = client.session.calls[0]
assert url == f"{STABLE}/historical-price-eod/full"
assert params == {"symbol": "SPY", "from": "2025-06-26", "to": "2025-06-30"}
assert client.get_historical_prices("SPY", days=2) is out # cached
def test_historical_stable_wrong_symbol_then_v3_dict(self, make_client):
def handler(url, params):
if url.startswith(STABLE):
return ok([{"symbol": "QQQ", "date": "d", "close": 1}])
return ok({"symbol": "SPY", "historical": [{"date": "d", "close": 7}]})
client = make_client(handler)
out = client.get_historical_prices("SPY", days=5)
assert out == {"symbol": "SPY", "historical": [{"date": "d", "close": 7}]}
def test_historical_v3_symbol_mismatch_rejected(self, make_client):
def handler(url, params):
if url.startswith(STABLE):
return fail(500)
return ok({"symbol": "QQQ", "historical": [{"date": "d"}]})
assert make_client(handler).get_historical_prices("SPY") is None
def test_historical_dict_without_historical_key_rejected(self, make_client):
def handler(url, params):
return fail(500) if url.startswith(STABLE) else ok({"symbol": "SPY"})
assert make_client(handler).get_historical_prices("SPY") is None
def test_historical_stock_list_picks_requested_symbol(self, make_client):
payload = {
"historicalStockList": [
{"symbol": "QQQ", "historical": [{"date": "q"}]},
{"symbol": "SPY", "historical": [{"date": "s"}]},
]
}
def handler(url, params):
return fail(500) if url.startswith(STABLE) else ok(payload)
out = make_client(handler).get_historical_prices("SPY")
assert out == {"symbol": "SPY", "historical": [{"date": "s"}]}
def test_historical_stock_list_without_symbol_rejected(self, make_client):
payload = {"historicalStockList": [{"symbol": "QQQ", "historical": []}]}
def handler(url, params):
return fail(500) if url.startswith(STABLE) else ok(payload)
assert make_client(handler).get_historical_prices("SPY") is None
class TestFMPEndpointsWrappers:
def test_income_statement_stable_success_cached(self, make_client):
data = [{"date": "2024-12-31", "eps": 1.0}]
client = make_client(lambda url, p: ok(data))
assert client.get_income_statement("AAPL", period="annual", limit=5) == data
assert client.session.calls[0] == (
f"{STABLE}/income-statement",
{"period": "annual", "limit": 5, "symbol": "AAPL"},
)
client.get_income_statement("AAPL", period="annual", limit=5)
assert len(client.session.calls) == 1
def test_income_statement_falls_back_to_v3(self, make_client):
data = [{"date": "x"}]
def handler(url, params):
return fail(402) if url.startswith(STABLE) else ok(data)
client = make_client(handler)
assert client.get_income_statement("AAPL") == data
assert client.session.calls[1] == (
f"{V3}/income-statement/AAPL",
{"period": "quarter", "limit": 8},
)
def test_income_statement_both_fail(self, make_client):
client = make_client(lambda url, p: fail(500))
assert client.get_income_statement("AAPL") is None
assert client.cache == {}
def test_profile_exposes_mktcap_from_marketcap(self, make_client):
client = make_client(
lambda url, p: ok(
[{"symbol": "X", "marketCap": 5e9}, "junk", {"mktCap": 1, "marketCap": 2}]
)
)
profile = client.get_profile("X")
assert profile[0]["mktCap"] == 5e9
assert profile[2]["mktCap"] == 1 # existing value is not overwritten
assert client.get_profile("X") is profile
def test_profile_v3_fallback_and_failure(self, make_client):
def handler(url, params):
return fail(404) if url.startswith(STABLE) else ok([{"symbol": "X", "mktCap": 3}])
client = make_client(handler)
assert client.get_profile("X") == [{"symbol": "X", "mktCap": 3}]
assert make_client(lambda url, p: fail(500)).get_profile("X") is None
class TestInstitutionalHolders:
def test_recent_13f_quarters_wrap_year(self):
got = list(FMPClient._recent_13f_quarters(date(2025, 8, 15), count=4))
assert got == [(2025, 2), (2025, 1), (2024, 4), (2024, 3)]
jan = list(FMPClient._recent_13f_quarters(date(2025, 1, 10), count=2))
assert jan == [(2024, 4), (2024, 3)]
def test_stable_summary_walks_back_to_available_quarter(self, make_client, monkeypatch):
class FixedDate(date):
@classmethod
def today(cls):
return date(2025, 2, 10)
monkeypatch.setattr(fmp_client, "date", FixedDate)
def handler(url, params):
if url.endswith("symbol-positions-summary"):
if (params["year"], params["quarter"]) == (2024, 3):
return ok([{"investorsHolding": 120, "ownershipPercent": 55.5}])
return ok([])
if url.endswith("extract-analytics/holder"):
return ok(
[
{"investorName": "Fund A", "sharesNumber": 10, "changeInSharesNumber": 2},
"junk",
]
)
return fail(404)
client = make_client(handler)
result = client.get_institutional_holders("AAPL")
assert result == {
"num_holders": 120,
"ownership_pct": 55.5,
"top_holders": [{"holder": "Fund A", "shares": 10, "change": 2}],
}
summary_calls = [c for c in client.session.calls if c[0].endswith("positions-summary")]
assert [(c[1]["year"], c[1]["quarter"]) for c in summary_calls] == [(2024, 4), (2024, 3)]
top_call = [c for c in client.session.calls if c[0].endswith("holder")][0]
assert top_call[1]["page"] == 0
assert client.get_institutional_holders("AAPL") is result # cached
def test_stable_summary_with_missing_top_holders(self, make_client):
def handler(url, params):
if url.endswith("symbol-positions-summary"):
return ok([{"investorsHolding": 5}])
return fail(402)
result = make_client(handler).get_institutional_holders("AAPL")
assert result == {"num_holders": 5, "ownership_pct": None, "top_holders": []}
def test_v3_fallback_when_stable_unavailable(self, make_client):
holders = [{"holder": f"H{i}", "shares": i, "change": 0} for i in range(12)]
def handler(url, params):
return ok(holders) if url.startswith(V3) else fail(402)
result = make_client(handler).get_institutional_holders("AAPL")
assert result["num_holders"] == 12
assert result["ownership_pct"] is None
assert result["top_holders"] == holders[:10]
def test_all_sources_fail_returns_none(self, make_client):
client = make_client(lambda url, p: fail(403))
assert client.get_institutional_holders("AAPL") is None
assert "institutional_AAPL" not in client.cache
class TestFMPClientMisc:
def test_calculate_ema_matches_manual_computation(self, make_client):
client = make_client(lambda url, p: ok({}))
assert client.calculate_ema([2.0, 4.0], period=5) == 3.0
# newest-first [30, 20, 10] with period 2: SMA(10,20)=15, then 30*2/3 + 15/3 = 25
assert client.calculate_ema([30.0, 20.0, 10.0], period=2) == pytest.approx(25.0)
def test_clear_cache_and_stats(self, make_client, capsys):
client = make_client(lambda url, p: ok([{"symbol": "AAPL"}]))
client.get_quote("AAPL")
assert client.get_api_stats() == {
"cache_entries": 1,
"rate_limit_reached": False,
"retry_count": 0,
}
client.clear_cache()
assert client.cache == {}
assert "Cache cleared" in capsys.readouterr().err
def test_builtin_smoke_function_reports_success(self, monkeypatch, capsys):
class Smoke:
def get_quote(self, symbols):
if "," in symbols:
return [{"symbol": "^GSPC", "price": 10.0}, {"symbol": "^VIX", "price": 20.0}]
return [{"symbol": "AAPL", "price": 10.0}]
def get_income_statement(self, symbol, period="quarter", limit=8):
return [{"date": "2024-01-01", "eps": 1.5}]
def get_historical_prices(self, symbol, days=365):
return {"historical": [{"date": "2024-01-01", "close": 2.0}]}
def get_api_stats(self):
return {"cache_entries": 4, "rate_limit_reached": False}
monkeypatch.setattr(fmp_client, "FMPClient", Smoke)
fmp_client.test_client()
out = capsys.readouterr().out
assert "Quote: AAPL @ $10.00" in out
assert "Latest quarter: 2024-01-01, EPS: $1.5" in out
assert "Latest year: 2024-01-01, EPS: $1.5" in out
assert "Fetched 1 days of price history" in out
assert "^VIX: 20.00" in out
assert "Cache entries: 4" in out
def test_builtin_smoke_function_reports_failures(self, monkeypatch, capsys):
class Broken:
def get_quote(self, symbols):
return None
def get_income_statement(self, symbol, period="quarter", limit=8):
return None
def get_historical_prices(self, symbol, days=365):
return None
def get_api_stats(self):
return {"cache_entries": 0, "rate_limit_reached": True}
monkeypatch.setattr(fmp_client, "FMPClient", Broken)
fmp_client.test_client()
out = capsys.readouterr().out
assert "Quote failed" in out
assert "Quarterly income statement failed" in out
assert "Annual income statement failed" in out
assert "Historical prices failed" in out
assert "Market indices failed" in out
assert "Rate limit reached: True" in out
# ---------------------------------------------------------------- Finviz client
FINVIZ_HTML = """
<html><body>
<table class="snapshot-table2">
<tr><td>Inst Own</td><td>61.25%</td><td>Inst Trans</td><td>-1.50%</td></tr>
<tr><td>P/E</td><td>25.1</td><td>Odd</td></tr>
</table>
</body></html>
"""
@pytest.fixture
def finviz(monkeypatch):
sleeps = []
monkeypatch.setattr(fsc.time, "sleep", sleeps.append)
client = fsc.FinvizStockClient(rate_limit_seconds=2.0)
client.sleeps = sleeps
return client
class TestFinvizClient:
def test_parse_page_extracts_label_value_pairs(self, finviz):
data = finviz._parse_finviz_page(FINVIZ_HTML)
assert data == {
"Inst Own": "61.25%",
"Inst Trans": "-1.50%",
"P/E": "25.1",
}
def test_parse_page_without_table_is_empty(self, finviz):
assert finviz._parse_finviz_page("<html><body>nothing</body></html>") == {}
@pytest.mark.parametrize(
"text,expected",
[("60.50%", 60.5), ("-", None), ("", None), (None, None), ("abc%", None), ("-1.5%", -1.5)],
)
def test_parse_percentage(self, text, expected):
assert fsc.FinvizStockClient._parse_percentage(text) == expected
def test_institutional_ownership_success_and_cache(self, finviz):
finviz.session = FakeSession(lambda url, p: FakeResponse(200, text=FINVIZ_HTML))
result = finviz.get_institutional_ownership("AAPL")
assert result == {"inst_own_pct": 61.25, "inst_trans_pct": -1.5, "error": None}
assert finviz.session.calls[0][0] == "https://finviz.com/quote.ashx?t=AAPL"
assert finviz.get_institutional_ownership("AAPL") is result
assert len(finviz.session.calls) == 1
def test_missing_ownership_fields_give_none(self, finviz):
html = '<table class="snapshot-table2"><tr><td>P/E</td><td>9</td></tr></table>'
finviz.session = FakeSession(lambda url, p: FakeResponse(200, text=html))
result = finviz.get_institutional_ownership("ZZZ")
assert result == {"inst_own_pct": None, "inst_trans_pct": None, "error": None}
def test_http_error_returns_error_result(self, finviz, capsys):
finviz.session = FakeSession(lambda url, p: FakeResponse(403, text="no"))
result = finviz.get_institutional_ownership("AAPL")
assert result["inst_own_pct"] is None
assert result["error"] == "Failed to fetch data from Finviz for AAPL"
assert "status 403" in capsys.readouterr().err
def test_exception_returns_error_result(self, finviz, capsys):
finviz.session = FakeSession(lambda url, p: RuntimeError("net down"))
result = finviz.get_institutional_ownership("AAPL")
assert result["error"] == "Failed to fetch data from Finviz for AAPL"
assert "net down" in capsys.readouterr().err
def test_rate_limit_sleeps_between_requests(self, finviz, monkeypatch):
monkeypatch.setattr(fsc.time, "time", lambda: 100.0)
finviz.session = FakeSession(lambda url, p: FakeResponse(200, text=FINVIZ_HTML))
finviz.get_stock_data("AAA")
assert finviz.sleeps == []
finviz.get_stock_data("BBB")
assert finviz.sleeps == [pytest.approx(2.0)]
def test_get_stock_data_returns_full_dict_and_caches(self, finviz):
finviz.session = FakeSession(lambda url, p: FakeResponse(200, text=FINVIZ_HTML))
data = finviz.get_stock_data("AAPL")
assert data["P/E"] == "25.1"
assert finviz.get_stock_data("AAPL") is data
assert len(finviz.session.calls) == 1
def test_get_stock_data_failure_returns_none_uncached(self, finviz):
finviz.session = FakeSession(lambda url, p: FakeResponse(500, text=""))
assert finviz.get_stock_data("AAPL") is None
assert "finviz_full_AAPL" not in finviz.cache
def test_empty_page_counts_as_failure(self, finviz):
finviz.session = FakeSession(lambda url, p: FakeResponse(200, text="<html></html>"))
result = finviz.get_institutional_ownership("AAPL")
assert result["error"] == "Failed to fetch data from Finviz for AAPL"
# ---------------------------------------------------------------- check_institutional_endpoint
class TestCheckInstitutionalEndpoint:
def test_latest_completed_quarter(self):
assert cie._latest_completed_quarter(date(2025, 2, 1)) == (2024, 4)
assert cie._latest_completed_quarter(date(2025, 4, 1)) == (2025, 1)
assert cie._latest_completed_quarter(date(2025, 12, 31)) == (2025, 3)
def _patch_quarter(self, monkeypatch):
monkeypatch.setattr(cie, "_latest_completed_quarter", lambda as_of=None: (2025, 2))
def test_missing_key(self, monkeypatch, capsys):
monkeypatch.delenv("FMP_API_KEY", raising=False)
assert cie.check_institutional_endpoint() is False
assert "FMP_API_KEY environment variable not set" in capsys.readouterr().out
def test_available(self, monkeypatch, capsys):
monkeypatch.setenv("FMP_API_KEY", TEST_KEY)
self._patch_quarter(monkeypatch)
seen = {}
def fake_get(url, params=None, timeout=None):
seen["url"], seen["params"] = url, params
return FakeResponse(200, [{"investorsHolding": 321, "ownershipPercent": 61.5}], "x" * 7)
monkeypatch.setattr(cie.requests, "get", fake_get)
assert cie.check_institutional_endpoint() is True
out = capsys.readouterr().out
assert "AAPL: 321 holders, ownership 61.5%" in out
assert "year=2025 Q2" in out
assert "Response length: 7 bytes" in out
assert seen["params"] == {
"symbol": "AAPL",
"year": 2025,
"quarter": 2,
"apikey": TEST_KEY,
}
assert seen["url"].endswith("/stable/institutional-ownership/symbol-positions-summary")
@pytest.mark.parametrize("payload", [[], [{"investorsHolding": 0}], {"Error": "x"}])
def test_no_data_for_latest_quarter(self, monkeypatch, capsys, payload):
monkeypatch.setenv("FMP_API_KEY", TEST_KEY)
self._patch_quarter(monkeypatch)
monkeypatch.setattr(cie.requests, "get", lambda *a, **k: FakeResponse(200, payload, "t"))
assert cie.check_institutional_endpoint() is False
assert "No institutional data for the latest quarter" in capsys.readouterr().out
@pytest.mark.parametrize(
"status,msg",
[(401, "RESTRICTED"), (403, "RESTRICTED"), (500, "Unexpected status code 500")],
)
def test_error_statuses(self, monkeypatch, capsys, status, msg):
monkeypatch.setenv("FMP_API_KEY", TEST_KEY)
self._patch_quarter(monkeypatch)
monkeypatch.setattr(cie.requests, "get", lambda *a, **k: FakeResponse(status, None, ""))
assert cie.check_institutional_endpoint() is False
assert msg in capsys.readouterr().out
def test_request_exception(self, monkeypatch, capsys):
monkeypatch.setenv("FMP_API_KEY", TEST_KEY)
self._patch_quarter(monkeypatch)
def boom(*a, **k):
raise requests.exceptions.Timeout("timed out")
monkeypatch.setattr(cie.requests, "get", boom)
assert cie.check_institutional_endpoint() is False
assert "Request failed - timed out" in capsys.readouterr().out
@pytest.mark.parametrize("key,code", [(None, 1), (TEST_KEY, 0)])
def test_script_entrypoint_exit_codes(self, monkeypatch, capsys, key, code):
if key is None:
monkeypatch.delenv("FMP_API_KEY", raising=False)
else:
monkeypatch.setenv("FMP_API_KEY", key)
payload = [{"investorsHolding": 9, "ownershipPercent": 1.0}]
monkeypatch.setattr(requests, "get", lambda *a, **k: FakeResponse(200, payload, "t"))
with pytest.raises(SystemExit) as exc:
runpy.run_path(cie.__file__, run_name="__main__")
assert exc.value.code == code
assert "FMP Institutional-Holder Endpoint Availability Test" in capsys.readouterr().out
# ---------------------------------------------------------------- scorer
class TestScorer:
def test_phase1_composite(self):
result = scorer.calculate_composite_score(c_score=100, a_score=95, n_score=98, m_score=100)
# 27 + 34.2 + 26.46 + 10 = 97.66
assert result["composite_score"] == 97.7
assert result["rating"] == "Exceptional+"
assert result["weakest_component"] == "A"
assert result["weakest_score"] == 95
assert result["component_scores"] == {"C": 100, "A": 95, "N": 98, "M": 100}
def test_phase1_weights_sum_to_one(self):
assert sum(scorer.WEIGHTS_PHASE1.values()) == pytest.approx(1.0)
assert sum(scorer.WEIGHTS_PHASE2.values()) == pytest.approx(1.0)
assert sum(scorer.WEIGHTS_PHASE3.values()) == pytest.approx(1.0)
def test_phase2_composite(self):
result = scorer.calculate_composite_score_phase2(
c_score=100, a_score=100, n_score=0, s_score=50, i_score=100, m_score=100
)
# 19 + 25 + 0 + 9 + 13 + 6 = 72
assert result["composite_score"] == 72.0
assert result["rating"] == "Strong"
assert result["weakest_component"] == "N"
assert result["component_scores"]["S"] == 50
def test_phase3_composite(self):
result = scorer.calculate_composite_score_phase3(95, 90, 88, 85, 92, 80, 100)
# 14.25 + 18 + 13.2 + 12.75 + 18.4 + 8 + 5 = 89.6
assert result["composite_score"] == 89.6
assert result["rating"] == "Exceptional"
assert result["weakest_component"] == "I"
assert result["weakest_score"] == 80
assert set(result["component_scores"]) == set("CANSLIM")
@pytest.mark.parametrize(
"composite,rating,guidance_start",
[
(95, "Exceptional+", "Immediate buy"),
(85, "Exceptional", "Strong buy"),
(75, "Strong", "Buy, standard"),
(65, "Above Average", "Buy on pullback"),
(55, "Average", "Watchlist only"),
(45, "Below Average", "Monitor"),
(10, "Weak", "Avoid"),
(90, "Exceptional+", "Immediate buy"),
(40, "Below Average", "Monitor"),
],
)
def test_interpret_composite_score(self, composite, rating, guidance_start):
info = scorer.interpret_composite_score(composite)
assert info["rating"] == rating
assert info["guidance"].startswith(guidance_start)
def test_phase1_thresholds(self):
ok_ = scorer.check_minimum_thresholds(70, 60, 65, 70)
assert ok_ == {
"passes_all": True,
"failed_components": [],
"recommendation": "buy",
"reason": "All minimum thresholds met",
}
one = scorer.check_minimum_thresholds(50, 60, 65, 70)
assert (one["recommendation"], one["failed_components"]) == ("watchlist", ["C"])
many = scorer.check_minimum_thresholds(50, 45, 35, 60)
assert many["recommendation"] == "avoid"
assert many["failed_components"] == ["C", "A", "N"]
assert many["reason"] == "Multiple components below threshold: C, A, N"
bear = scorer.check_minimum_thresholds(95, 90, 92, 0)
assert bear["recommendation"] == "avoid"
assert bear["failed_components"] == ["M"]
assert "Bear market" in bear["reason"]
def test_phase2_thresholds(self):
assert scorer.check_minimum_thresholds_phase2(60, 50, 40, 40, 40, 40)["passes_all"] is True
one = scorer.check_minimum_thresholds_phase2(60, 50, 40, 39, 40, 40)
assert (one["recommendation"], one["failed_components"]) == ("watchlist", ["S"])
many = scorer.check_minimum_thresholds_phase2(10, 10, 40, 40, 40, 40)
assert many["recommendation"] == "avoid"
assert many["failed_components"] == ["C", "A"]
bear = scorer.check_minimum_thresholds_phase2(99, 99, 99, 99, 99, 0)
assert bear["failed_components"] == ["M"]
assert bear["recommendation"] == "avoid"
def test_phase3_thresholds_buy_watchlist_avoid(self):
buy = scorer.check_minimum_thresholds_phase3(60, 50, 40, 40, 50, 40, 40)
assert buy["passes_all"] is True
assert "All 7 CANSLIM thresholds met" in buy["reason"]
watch = scorer.check_minimum_thresholds_phase3(60, 50, 40, 40, 45, 40, 40)
assert (watch["recommendation"], watch["failed_components"]) == ("watchlist", ["L"])
many = scorer.check_minimum_thresholds_phase3(0, 0, 40, 40, 50, 40, 40)
assert many["recommendation"] == "avoid"
assert many["failed_components"] == ["C", "A"]
def test_phase3_low_leadership_forces_avoid_even_if_only_failure(self):
result = scorer.check_minimum_thresholds_phase3(90, 90, 90, 90, 39, 90, 90)
assert result["recommendation"] == "avoid"
assert result["failed_components"] == ["L"]
assert "L=39" in result["reason"]
def test_phase3_low_leadership_keeps_other_failures_listed(self):
result = scorer.check_minimum_thresholds_phase3(10, 90, 90, 90, 20, 90, 90)
assert result["failed_components"] == ["C", "L"]
assert result["recommendation"] == "avoid"
def test_phase3_bear_market_overrides_leadership(self):
result = scorer.check_minimum_thresholds_phase3(90, 90, 90, 90, 10, 90, 0)
assert result["failed_components"] == ["M"]
assert "Bear market" in result["reason"]
@pytest.mark.parametrize(
"score,rng,rating",
[
(95, "160-200", "Exceptional"),
(85, "140-159", "Strong"),
(75, "120-139", "Above Average"),
(65, "105-119", "Average"),
(59.9, "<105", "Below Average"),
],
)
def test_compare_to_full_canslim(self, score, rng, rating):
result = scorer.compare_to_full_canslim(score)
assert result["estimated_full_range"] == rng
assert result["equivalent_rating"] == rating
assert result["phase1_score"] == score
assert "4 of 7 components" in result["note"]
# ---------------------------------------------------------------- report generator
def make_result(symbol="AAA", score=95.0, **overrides):
result = {
"symbol": symbol,
"company_name": f"{symbol} Corp",
"price": 123.456,
"market_cap": 2.5e9,
"sector": "Technology",
"composite_score": score,
"rating": "Exceptional+",
"rating_description": "Rare setup",
"guidance": "Buy now",
"weakest_component": "A",
"weakest_score": 90,
"c_component": {
"score": 100,
"latest_qtr_eps_growth": 42.0,
"latest_qtr_revenue_growth": 12.5,
},
"a_component": {"score": 90, "eps_cagr_3yr": 33.33, "stability": "stable"},
"n_component": {"score": 80, "distance_from_high_pct": -3.2, "breakout_detected": True},
"s_component": {"score": 80, "up_down_ratio": 1.789, "accumulation_detected": True},
"l_component": {
"score": 90,
"rs_3m_return": 10.0,
"rs_6m_return": 20.0,
"rs_12m_return": 30.0,
"rel_3m": 1.0,
"rel_6m": 2.0,
"rel_12m": 3.0,
"rs_rating": "Strong",
"rs_rank_percentile": 80,
},
"i_component": {
"score": 70,
"num_holders": 75,
"ownership_pct": 45.55,
"superinvestor_present": True,
},
"m_component": {"score": 90, "trend": "strong_uptrend"},
}
result.update(overrides)
return result
BASE_META = {
"generated_at": "2025-01-01 00:00:00 UTC",
"phase": "3.1 (test)",
"candidates_analyzed": 2,
}
def render(tmp_path, results, metadata=None):
path = tmp_path / "report.md"
rg.generate_markdown_report(results, metadata or dict(BASE_META), str(path))
return path.read_text(encoding="utf-8")
class TestReportGenerator:
def test_summary_stats_buckets(self):
results = [{"composite_score": s} for s in (95, 90, 85, 80, 79.9, 70, 65, 60, 59.9, 0)]
assert rg.generate_summary_stats(results) == {
"total_stocks": 10,
"exceptional": 2,
"strong": 2,
"above_average": 2,
"average": 2,
"below_average": 2,
}
def test_summary_stats_empty(self):
assert rg.generate_summary_stats([]) == {
"total_stocks": 0,
"exceptional": 0,
"strong": 0,
"above_average": 0,
"average": 0,
"below_average": 0,
}
@pytest.mark.parametrize(
"score,emoji",
[(95, "⭐⭐⭐"), (85, "⭐⭐"), (75, "⭐"), (65, "✓"), (59, "")],
)
def test_rating_emoji(self, score, emoji):
assert rg.get_rating_emoji(score) == emoji
def test_json_report_roundtrip(self, tmp_path, capsys):
path = tmp_path / "out.json"
results = [make_result("AAA", 95.0), make_result("BBB", 65.0)]
rg.generate_json_report(results, {"k": "v"}, str(path))
report = json.loads(path.read_text(encoding="utf-8"))
assert report["metadata"] == {"k": "v"}
assert [r["symbol"] for r in report["results"]] == ["AAA", "BBB"]
assert report["summary"]["exceptional"] == 1
assert report["summary"]["average"] == 1
assert "JSON report saved to" in capsys.readouterr().out
def test_markdown_header_and_defaults(self, tmp_path):
text = render(tmp_path, [make_result()])
assert "**Generated:** 2025-01-01 00:00:00 UTC" in text
assert "**Phase:** 3.1 (test) (Components: C, A, N, S, I, M)" in text
assert "**Stocks Analyzed:** 2" in text
assert "Schema Version" not in text
assert "RS Benchmark" not in text
assert "## Market Condition Summary" not in text
def test_markdown_metadata_sections(self, tmp_path):
meta = dict(
BASE_META,
schema_version="3.1",
components_included=["C", "A"],
screening_options={"rs_benchmark": "SPY", "rs_disabled": True},
market_condition={"trend": "bear_market", "M_score": 0, "warning": "Go to cash"},
)
text = render(tmp_path, [make_result()], meta)
assert "(Components: C, A)" in text
assert "**Schema Version:** 3.1" in text
assert "**RS Benchmark:** SPY (DISABLED via --disable-rs)" in text
assert "RS Disabled: L component fixed at neutral 50" in text
assert "- **Trend:** bear_market" in text
assert "- **M Score:** 0/100" in text
assert "- **⚠️ Warning:** Go to cash" in text
def test_rs_disabled_without_benchmark_defaults_label(self, tmp_path):
meta = dict(BASE_META, screening_options={"rs_disabled": True})
text = render(tmp_path, [make_result()], meta)
assert "**RS Benchmark:** ^GSPC (DISABLED via --disable-rs)" in text
def test_rs_benchmark_only(self, tmp_path):
meta = dict(BASE_META, screening_options={"rs_benchmark": "QQQ"})
text = render(tmp_path, [make_result()], meta)
assert "**RS Benchmark:** QQQ\n" in text
assert "DISABLED" not in text
def test_market_condition_without_optional_keys(self, tmp_path):
text = render(tmp_path, [make_result()], dict(BASE_META, market_condition={}))
assert "- **Trend:** unknown" in text
assert "- **M Score:** N/A/100" in text
assert "Warning" not in text.split("## Summary Table")[0]
def test_summary_table_rows(self, tmp_path):
weak_l = {"score": 10, "rs_rank_percentile": None}
text = render(
tmp_path,
[
make_result("AAA", 95.0),
make_result("BBB", 61.234, rating="Above Average", l_component=weak_l),
],
)
assert "| 1 | AAA | 95.0 | Exceptional+ | Strong | 80 |" in text
assert "| 2 | BBB | 61.2 | Above Average | N/A | N/A |" in text
assert "## Top 2 CANSLIM Candidates" in text
assert "### 1. AAA - AAA Corp ⭐⭐⭐" in text
assert "### 2. BBB - BBB Corp ✓" in text
def test_empty_results_report(self, tmp_path):
text = render(tmp_path, [])
assert "## Summary Table" not in text
assert "## Top 0 CANSLIM Candidates" in text
assert "- **Total Stocks Screened:** 0" in text
assert "## Methodology" in text
assert "**Disclaimer:**" in text
def test_summary_statistics_section(self, tmp_path):
text = render(tmp_path, [make_result("A", 95), make_result("B", 85), make_result("C", 10)])
assert "- **Total Stocks Screened:** 3" in text
assert "- **Exceptional (90+):** 1 stocks" in text
assert "- **Strong (80-89):** 1 stocks" in text
assert "- **Below Average (<60):** 1 stocks" in text
def test_stock_entry_basic_fields(self):
lines = rg.format_stock_entry(3, make_result())
text = "\n".join(lines)
assert lines[0] == "### 3. AAA - AAA Corp ⭐⭐⭐"
assert "**Price:** $123.46 | **Market Cap:** $2.5B | **Sector:** Technology" in text
assert "**Composite Score:** 95.0/100 (Exceptional+)" in text
assert "| 🅲 Current Earnings | 100/100 | EPS: +42.0%, Revenue: +12.5% |" in text
assert "| 🅰 Annual Growth | 90/100 | 3yr CAGR: 33.3%, stable |" in text
assert "| 🅽 Newness | 80/100 | -3.2% from 52wk high ✓ Breakout |" in text
assert "Up/Down Volume Ratio: 1.79 ✓ Accumulation |" in text
assert "| 🅸 Institutional | 70/100 | 75 holders, 45.5% ownership ⭐ Superinvestor |" in text
assert "| 🅼 Market Direction | 90/100 | Strong Uptrend |" in text
assert "**Rating:** Exceptional+ - Rare setup" in text
assert "**Guidance:** Buy now" in text
assert "**Weakest Component:** A (90/100)" in text
assert "**Warnings:**" not in text
def test_stock_entry_multi_period_leadership_with_benchmark(self):
text = "\n".join(rg.format_stock_entry(1, make_result()))
assert (
"| 🅻 Leadership | 90/100 | 3m/6m/12m: +10.0%/+20.0%/+30.0% "
"(rel +1.0%/+2.0%/+3.0%) | RS: 80 (Strong) |"
) in text
def test_stock_entry_multi_period_leadership_without_benchmark(self):
l_comp = {
"score": 40,
"rs_3m_return": 5.0,
"rs_6m_return": None,
"rs_12m_return": None,
"rel_3m": None,
"rel_6m": None,
"rel_12m": None,
}
text = "\n".join(rg.format_stock_entry(1, make_result(l_component=l_comp)))
assert "| 🅻 Leadership | 40/100 | 3m/6m/12m: +5.0%/N/A/N/A (no benchmark) |" in text
def test_stock_entry_skipped_leadership(self):
text = "\n".join(
rg.format_stock_entry(1, make_result(l_component={"score": 50, "skipped": True}))
)
assert "| 🅻 Leadership | 50/100 | Skipped via --disable-rs (neutral 50) |" in text
def test_stock_entry_legacy_leadership_with_rank(self):
l_comp = {
"score": 80,
"stock_52w_performance": 25.0,
"relative_performance": 12.0,
"rs_rank_estimate": 80,
"rs_rating": "Strong",
}
text = "\n".join(rg.format_stock_entry(1, make_result(l_component=l_comp)))
assert (
"| 🅻 Leadership | 80/100 | 52wk: +25.0% (+12.0% vs benchmark) | RS: 80 (Strong) |"
in text
)
def test_stock_entry_legacy_leadership_rank_without_rating_or_benchmark(self):
l_comp = {"score": 60, "stock_52w_performance": 5.0, "rs_rank_estimate": 54}
text = "\n".join(rg.format_stock_entry(1, make_result(l_component=l_comp)))
assert "52wk: +5.0% ((no benchmark)) | RS: 54 |" in text
def test_stock_entry_missing_optional_inputs(self):
stock = make_result(price=None, market_cap=None)
del stock["sector"]
stock["c_component"] = {}
stock["a_component"] = {}
stock["n_component"] = {}
stock["s_component"] = {}
stock["i_component"] = {}
stock["m_component"] = {}
stock["l_component"] = {}
text = "\n".join(rg.format_stock_entry(1, stock))
assert "**Price:** N/A | **Market Cap:** N/A | **Sector:** N/A" in text
assert "| 🅲 Current Earnings | 0/100 | EPS: N/A, Revenue: N/A |" in text
assert "| 🅰 Annual Growth | 0/100 | 3yr CAGR: N/A, unknown |" in text
assert "| 🅼 Market Direction | 0/100 | Unknown |" in text
assert "| 🅸 Institutional | 0/100 | N/A holders, N/A ownership |" in text
assert "| 🅻 Leadership | 0/100 | 52wk: N/A ((no benchmark)) |" in text
def test_stock_entry_none_values_render_as_none_text(self):
"""Pin: None (as emitted by failed calculators) is rendered literally as 'None'."""
stock = make_result()
stock["c_component"] = {
"score": 0,
"latest_qtr_eps_growth": None,
"latest_qtr_revenue_growth": None,
}
stock["i_component"] = {"score": 0, "num_holders": 0, "ownership_pct": None}
text = "\n".join(rg.format_stock_entry(1, stock))
assert "EPS: None, Revenue: None" in text
assert "0 holders, None ownership" in text
def test_stock_entry_warnings_from_every_component(self):
stock = make_result()
stock["c_component"]["quality_warning"] = "c warn"
stock["a_component"]["quality_warning"] = "a warn"
stock["s_component"]["quality_warning"] = "s warn"
stock["l_component"]["quality_warning"] = "l warn"
stock["i_component"]["quality_warning"] = "i warn"
stock["m_component"]["warning"] = "m warn"
lines = rg.format_stock_entry(1, stock)
start = lines.index("**Warnings:**")
assert lines[start + 1 : start + 7] == [
"- ⚠️ c warn",
"- ⚠️ a warn",
"- ⚠️ s warn",
"- ⚠️ l warn",
"- ⚠️ i warn",
"- ⚠️ m warn",
]
def test_markdown_file_written_and_announced(self, tmp_path, capsys):
render(tmp_path, [make_result()])
assert "Markdown report saved to" in capsys.readouterr().out
assert (tmp_path / "report.md").read_text(encoding="utf-8").endswith("\n")
# ---------------------------------------------------------------- screen_canslim helpers
def _iso(i):
return (date(2024, 1, 1) + timedelta(days=i)).isoformat()
def price_history(n, start, end, zigzag=0.0, up_vol=2000, down_vol=1000):
"""Newest-first FMP-style history; even chronological index = up day."""
step = (end - start) / (n - 1) if n > 1 else 0.0
chrono = []
for i in range(n):
close = start + step * i + (zigzag if i % 2 == 0 else 0.0)
chrono.append(
{
"date": _iso(i),
"close": close,
"volume": up_vol if i % 2 == 0 else down_vol,
}
)
return {"symbol": "X", "historical": chrono[::-1]}
def quarters(eps_latest, eps_year_ago, rev_latest, rev_year_ago):
rows = [{"date": f"q{i}", "eps": 1.0, "revenue": 100.0} for i in range(8)]
rows[0] = {"date": "2024-06-30", "eps": eps_latest, "revenue": rev_latest}
rows[4] = {"date": "2023-06-30", "eps": eps_year_ago, "revenue": rev_year_ago}
return rows
def annual(eps_newest_first, revenues):
return [
{"date": f"{2024 - i}-12-31", "eps": e, "revenue": r}
for i, (e, r) in enumerate(zip(eps_newest_first, revenues))
]
def strong_stock(symbol):
return {
"profile": [
{"symbol": symbol, "companyName": f"{symbol} Inc", "sector": "Tech", "mktCap": 3e9}
],
"quote": [
{
"symbol": symbol,
"price": 99.8,
"yearHigh": 100.0,
"yearLow": 50.0,
"volume": 1_500_000,
"avgVolume": 1_000_000,
}
],
"quarterly": quarters(3.0, 1.0, 300.0, 100.0),
"annual": annual([8, 4, 2, 1], [800.0, 400.0, 200.0, 100.0]),
"prices_90": price_history(90, 100, 110, zigzag=1.0),
"prices_365": price_history(365, 100, 1000),
"institutional": {"num_holders": 75, "ownership_pct": 45.0, "top_holders": []},
}
def weak_stock(symbol):
data = strong_stock(symbol)
data["quote"] = [
{
"symbol": symbol,
"price": 60.0,
"yearHigh": 100.0,
"yearLow": 50.0,
"volume": 1,
"avgVolume": 1,
}
]
data["quarterly"] = quarters(1.0, 1.0, 100.0, 100.0)
data["annual"] = annual([1, 1, 1, 1], [100.0, 100.0, 100.0, 100.0])
data["prices_365"] = price_history(365, 100, 100.5)
data["institutional"] = {"num_holders": 5, "ownership_pct": 5.0, "top_holders": []}
return data
class FakeFMP:
"""Stand-in for FMPClient driven by per-symbol dicts."""
def __init__(self, stocks, market=None, api_key=None):
self.stocks = stocks
self.market = market if market is not None else default_market()
self.api_key = api_key
self.calls = []
def _stock(self, symbol):
return self.stocks.get(symbol)
def get_profile(self, symbol):
self.calls.append(("profile", symbol))
stock = self._stock(symbol)
return stock["profile"] if stock else None
def get_quote(self, symbol):
self.calls.append(("quote", symbol))
if symbol in self.market["quotes"]:
return self.market["quotes"][symbol]
stock = self._stock(symbol)
return stock["quote"] if stock else None
def get_income_statement(self, symbol, period="quarter", limit=8):
stock = self._stock(symbol)
if not stock:
return None
return stock["quarterly" if period == "quarter" else "annual"]
def get_historical_prices(self, symbol, days=365):
self.calls.append(("prices", symbol, days))
if symbol in self.market["history"]:
return self.market["history"][symbol]
stock = self._stock(symbol)
if not stock:
return None
return stock["prices_90"] if days == 90 else stock["prices_365"]
def get_institutional_holders(self, symbol):
stock = self._stock(symbol)
return stock["institutional"] if stock else None
def get_api_stats(self):
return {"cache_entries": 7, "rate_limit_reached": False, "retry_count": 0}
def default_market(price=103.0, vix=12.0):
return {
"quotes": {
"^GSPC": [{"symbol": "^GSPC", "price": price}],
"^VIX": [{"symbol": "^VIX", "price": vix}],
},
"history": {
"^GSPC": price_history(365, 100.0, 100.0),
"SPY": price_history(365, 100.0, 110.0),
},
}
def market_result(score=100, trend="strong_uptrend"):
return {"score": score, "trend": trend, "interpretation": "test market", "warning": None}
# ---------------------------------------------------------------- analyze_stock
class TestAnalyzeStock:
def test_strong_stock_scores_all_components(self, capsys):
client = FakeFMP({"AAA": strong_stock("AAA")})
result = sc.analyze_stock(
"AAA", client, market_result(), rs_benchmark_historical=price_history(365, 100.0, 100.0)
)
assert result["symbol"] == "AAA"
assert result["company_name"] == "AAA Inc"
assert result["sector"] == "Tech"
assert result["market_cap"] == 3e9
assert result["price"] == 99.8
assert result["c_component"]["score"] == 100
assert result["a_component"]["score"] == 100
assert result["n_component"]["score"] == 100
assert result["s_component"]["score"] == 100
assert result["l_component"]["score"] == 100
assert result["i_component"]["score"] == 100
assert result["m_component"]["score"] == 100
assert result["composite_score"] == 100.0
assert result["rating"] == "Exceptional+"
assert result["threshold_check"]["recommendation"] == "buy"
assert "Score: 100.0 (Exceptional+)" in capsys.readouterr().out
def test_composite_is_consistent_with_components(self):
client = FakeFMP({"BBB": weak_stock("BBB")})
result = sc.analyze_stock("BBB", client, market_result(score=70, trend="uptrend"))
expected = scorer.calculate_composite_score_phase3(
result["c_component"]["score"],
result["a_component"]["score"],
result["n_component"]["score"],
result["s_component"]["score"],
result["l_component"]["score"],
result["i_component"]["score"],
70,
)
assert result["composite_score"] == expected["composite_score"]
assert result["weakest_component"] == expected["weakest_component"]
assert result["composite_score"] < 70
assert result["threshold_check"]["recommendation"] != "buy"
def test_profile_unavailable(self, capsys):
assert sc.analyze_stock("NOPE", FakeFMP({}), market_result()) is None
assert "Profile unavailable" in capsys.readouterr().out
def test_quote_unavailable(self, capsys):
stock = strong_stock("AAA")
stock["quote"] = None
assert sc.analyze_stock("AAA", FakeFMP({"AAA": stock}), market_result()) is None
assert "Quote unavailable" in capsys.readouterr().out
def test_missing_statements_and_prices_use_defaults(self, monkeypatch):
fallback = Mock(return_value=None)
monkeypatch.setattr(fsc.FinvizStockClient, "get_institutional_ownership", fallback)
stock = strong_stock("AAA")
stock["quarterly"] = None
stock["annual"] = None
stock["prices_90"] = None
stock["prices_365"] = None
stock["institutional"] = None
result = sc.analyze_stock("AAA", FakeFMP({"AAA": stock}), market_result())
assert result["c_component"] == {"score": 0, "error": "No quarterly data"}
assert result["a_component"] == {"score": 50, "error": "No annual data"}
assert result["s_component"] == {"score": 0, "error": "No price history data"}
assert result["l_component"]["score"] == 0
assert result["l_component"]["error"] == "No 52-week price history"
assert result["l_component"]["rs_benchmark"] == "^GSPC"
assert result["i_component"]["score"] == 0
fallback.assert_called_once_with("AAA")
def test_profile_defaults_when_fields_missing(self):
stock = strong_stock("AAA")
stock["profile"] = [{"symbol": "AAA"}]
result = sc.analyze_stock("AAA", FakeFMP({"AAA": stock}), market_result())
assert result["company_name"] == "AAA"
assert result["sector"] == "Unknown"
assert result["market_cap"] == 0
def test_disable_rs_skips_365_day_fetch_and_returns_neutral_l(self):
client = FakeFMP({"AAA": strong_stock("AAA")})
result = sc.analyze_stock(
"AAA", client, market_result(), disable_rs=True, rs_benchmark="SPY"
)
l_comp = result["l_component"]
assert l_comp["score"] == 50
assert l_comp["skipped"] is True
assert l_comp["rs_rating"] == "Skipped"
assert l_comp["rs_benchmark"] == "SPY"
assert l_comp["missing_periods"] == ["3m", "6m", "12m"]
assert ("prices", "AAA", 365) not in client.calls
assert ("prices", "AAA", 90) in client.calls
def test_rs_benchmark_history_changes_l_score(self):
stock = strong_stock("AAA")
bench = price_history(365, 100.0, 5000.0) # benchmark outruns the stock
result = sc.analyze_stock(
"AAA", FakeFMP({"AAA": stock}), market_result(), rs_benchmark_historical=bench
)
assert result["l_component"]["rel_12m"] < 0
assert result["l_component"]["score"] < 60
def test_missing_benchmark_history_applies_absolute_fallback_penalty(self):
result = sc.analyze_stock(
"AAA",
FakeFMP({"AAA": strong_stock("AAA")}),
market_result(),
rs_benchmark_historical=None,
)
l_comp = result["l_component"]
assert "unavailable" in l_comp["quality_warning"]
assert l_comp["score"] == 80 # int(100 * 0.8)
def test_exception_inside_analysis_returns_none(self, capsys):
class Exploding(FakeFMP):
def get_quote(self, symbol):
raise RuntimeError("kaput")
assert (
sc.analyze_stock("AAA", Exploding({"AAA": strong_stock("AAA")}), market_result())
is None
)
assert "Error: kaput" in capsys.readouterr().out
# ---------------------------------------------------------------- parse_arguments / main
def run_main(monkeypatch, argv, fake=None, real_client=False):
monkeypatch.setattr(sys, "argv", ["screen_canslim.py", *argv])
if not real_client:
monkeypatch.setattr(sc, "FMPClient", lambda api_key=None: fake)
sc.main()
def read_reports(out_dir):
jsons = sorted(out_dir.glob("canslim_screener_*.json"))
mds = sorted(out_dir.glob("canslim_screener_*.md"))
assert len(jsons) == 1 and len(mds) == 1
return json.loads(jsons[0].read_text(encoding="utf-8")), mds[0].read_text(encoding="utf-8")
class TestParseArguments:
def test_defaults(self, monkeypatch):
monkeypatch.setattr(sys, "argv", ["screen_canslim.py"])
args = sc.parse_arguments()
assert args.api_key is None
assert args.max_candidates == 40
assert args.top == 20
assert args.output_dir == "reports/"
assert args.universe is None
assert args.rs_benchmark == "^GSPC"
assert args.disable_rs is False
def test_all_options(self, monkeypatch):
monkeypatch.setattr(
sys,
"argv",
[
"screen_canslim.py",
"--api-key", "k",
"--max-candidates", "3",
"--top", "2",
"--output-dir", "out",
"--universe", "A", "B",
"--rs-benchmark", "QQQ",
"--disable-rs",
],
) # fmt: skip
args = sc.parse_arguments()
assert (args.api_key, args.max_candidates, args.top) == ("k", 3, 2)
assert args.output_dir == "out"
assert args.universe == ["A", "B"]
assert args.rs_benchmark == "QQQ"
assert args.disable_rs is True
def test_default_universe_has_40_unique_symbols(self):
assert len(sc.DEFAULT_UNIVERSE) == 40
assert len(set(sc.DEFAULT_UNIVERSE)) == 40
class TestMain:
def test_missing_api_key_exits_1(self, monkeypatch, capsys):
monkeypatch.delenv("FMP_API_KEY", raising=False)
with pytest.raises(SystemExit) as exc:
run_main(monkeypatch, [], real_client=True)
assert exc.value.code == 1
assert "FMP API key required" in capsys.readouterr().err
def test_missing_sp500_quote_exits_1(self, monkeypatch, capsys, tmp_path):
market = default_market()
market["quotes"]["^GSPC"] = None
fake = FakeFMP({}, market=market)
with pytest.raises(SystemExit) as exc:
run_main(monkeypatch, ["--output-dir", str(tmp_path)], fake)
assert exc.value.code == 1
assert "Unable to fetch S&P 500 data" in capsys.readouterr().err
assert list(tmp_path.iterdir()) == []
def test_full_run_writes_sorted_reports(self, monkeypatch, capsys, tmp_path):
stocks = {"AAA": strong_stock("AAA"), "BBB": weak_stock("BBB")}
fake = FakeFMP(stocks)
out_dir = tmp_path / "nested" / "reports"
run_main(
monkeypatch,
["--universe", "BBB", "AAA", "MISSING", "--output-dir", str(out_dir)],
fake,
)
report, md = read_reports(out_dir)
meta = report["metadata"]
assert meta["schema_version"] == "3.1"
assert meta["components_included"] == ["C", "A", "N", "S", "L", "I", "M"]
assert meta["candidates_analyzed"] == 2
assert meta["universe_size"] == 3
assert meta["screening_options"] == {"rs_benchmark": "^GSPC", "rs_disabled": False}
assert meta["market_condition"]["trend"] == "strong_uptrend"
assert meta["market_condition"]["M_score"] == 100
symbols = [r["symbol"] for r in report["results"]]
assert symbols == ["AAA", "BBB"]
assert report["results"][0]["composite_score"] > report["results"][1]["composite_score"]
assert report["summary"]["total_stocks"] == 2
assert "### 1. AAA - AAA Inc" in md
assert "**Stocks Analyzed:** 2" in md
out = capsys.readouterr().out
assert "Custom universe: 3 stocks" in out
assert "Successfully analyzed 2 stocks" in out
assert "1. AAA" in out
assert "S&P 500: $103.00" in out
assert "Trend: strong_uptrend" in out
assert "M Score: 100/100" in out
assert "Cache entries: 7" in out
assert "Estimated calls: ~24 (3 market data calls + 3 stocks × 7 API calls each)" in out
def test_top_limits_reports_but_not_count(self, monkeypatch, tmp_path):
stocks = {s: strong_stock(s) for s in ("AAA", "BBB", "CCC")}
run_main(
monkeypatch,
["--universe", "AAA", "BBB", "CCC", "--top", "2", "--output-dir", str(tmp_path)],
FakeFMP(stocks),
)
report, _ = read_reports(tmp_path)
assert len(report["results"]) == 2
assert report["metadata"]["candidates_analyzed"] == 3
def test_max_candidates_truncates_custom_universe(self, monkeypatch, capsys, tmp_path):
stocks = {s: strong_stock(s) for s in ("AAA", "BBB", "CCC")}
run_main(
monkeypatch,
[
"--universe",
"AAA",
"BBB",
"CCC",
"--max-candidates",
"2",
"--output-dir",
str(tmp_path),
],
FakeFMP(stocks),
)
report, _ = read_reports(tmp_path)
assert report["metadata"]["universe_size"] == 2
assert sorted(r["symbol"] for r in report["results"]) == ["AAA", "BBB"]
assert "Custom universe: 2 stocks" in capsys.readouterr().out
def test_default_universe_respects_max_candidates(self, monkeypatch, capsys, tmp_path):
fake = FakeFMP({})
run_main(monkeypatch, ["--max-candidates", "2", "--output-dir", str(tmp_path)], fake)
profile_calls = [c[1] for c in fake.calls if c[0] == "profile"]
assert profile_calls == sc.DEFAULT_UNIVERSE[:2]
report, md = read_reports(tmp_path)
assert report["results"] == []
assert "Default universe (S&P 500 top 2): 2 stocks" in capsys.readouterr().out
assert "## Top 0 CANSLIM Candidates" in md
def test_custom_rs_benchmark_is_fetched_and_reported(self, monkeypatch, capsys, tmp_path):
fake = FakeFMP({"AAA": strong_stock("AAA")})
run_main(
monkeypatch,
["--universe", "AAA", "--rs-benchmark", "SPY", "--output-dir", str(tmp_path)],
fake,
)
assert ("prices", "SPY", 365) in fake.calls
report, md = read_reports(tmp_path)
assert report["metadata"]["screening_options"]["rs_benchmark"] == "SPY"
assert report["results"][0]["l_component"]["rs_benchmark"] == "SPY"
assert "**RS Benchmark:** SPY" in md
out = capsys.readouterr().out
assert "SPY historical data: 365 days" in out
assert "Estimated calls: ~11 (4 market data calls + 1 stocks × 7 API calls each)" in out
def test_custom_rs_benchmark_unavailable_falls_back_with_penalty(
self, monkeypatch, capsys, tmp_path
):
market = default_market()
del market["history"]["SPY"]
fake = FakeFMP({"AAA": strong_stock("AAA")}, market=market)
run_main(
monkeypatch,
["--universe", "AAA", "--rs-benchmark", "SPY", "--output-dir", str(tmp_path)],
fake,
)
report, _ = read_reports(tmp_path)
assert report["results"][0]["l_component"]["score"] == 80
assert "SPY historical data unavailable" in capsys.readouterr().out
def test_disable_rs_skips_benchmark_and_adjusts_call_estimate(
self, monkeypatch, capsys, tmp_path
):
fake = FakeFMP({"AAA": strong_stock("AAA")})
run_main(
monkeypatch,
[
"--universe",
"AAA",
"--rs-benchmark",
"SPY",
"--disable-rs",
"--output-dir",
str(tmp_path),
],
fake,
)
assert ("prices", "SPY", 365) not in fake.calls
report, md = read_reports(tmp_path)
assert report["metadata"]["screening_options"]["rs_disabled"] is True
assert report["results"][0]["l_component"]["skipped"] is True
assert "DISABLED via --disable-rs" in md
out = capsys.readouterr().out
assert "--disable-rs set: L component will be fixed at neutral 50" in out
assert (
"Estimated calls: ~9 (3 market data calls + 1 stocks × 6 API calls each, --disable-rs)"
in out
)
def test_bear_market_prints_warning_and_records_it(self, monkeypatch, capsys, tmp_path):
market = default_market(price=90.0, vix=25.0)
fake = FakeFMP({"AAA": strong_stock("AAA")}, market=market)
run_main(monkeypatch, ["--universe", "AAA", "--output-dir", str(tmp_path)], fake)
out = capsys.readouterr().out
assert "WARNING: ⚠️ BEAR MARKET" in out
assert "CANSLIM doesn't work in bear markets" in out
assert "Distance from 50-EMA: -10.00%" in out
report, md = read_reports(tmp_path)
assert report["metadata"]["market_condition"]["M_score"] == 0
assert "BEAR MARKET" in report["metadata"]["market_condition"]["warning"]
assert report["results"][0]["threshold_check"]["recommendation"] == "avoid"
assert "- **M Score:** 0/100" in md
def test_missing_sp500_history_uses_neutral_market(self, monkeypatch, capsys, tmp_path):
market = default_market()
market["history"] = {}
fake = FakeFMP({"AAA": strong_stock("AAA")}, market=market)
run_main(monkeypatch, ["--universe", "AAA", "--output-dir", str(tmp_path)], fake)
out = capsys.readouterr().out
assert "^GSPC historical data unavailable" in out
assert "Distance from 50-EMA: N/A (insufficient history)" in out
assert "Trend: unknown" in out
report, md = read_reports(tmp_path)
# The run still publishes, but both artifacts say the M component is degraded.
assert report["metadata"]["market_condition"] == {
"trend": "unknown",
"M_score": 50,
"warning": None,
"error": "Insufficient S&P 500 history for 50-day EMA",
}
assert (
"- **⚠️ Data quality:** DEGRADED - M component: "
"Insufficient S&P 500 history for 50-day EMA"
) in md
def test_missing_vix_quote_is_tolerated(self, monkeypatch, tmp_path):
market = default_market()
market["quotes"]["^VIX"] = None
run_main(
monkeypatch,
["--universe", "AAA", "--output-dir", str(tmp_path)],
FakeFMP({"AAA": strong_stock("AAA")}, market=market),
)
report, _ = read_reports(tmp_path)
# strong uptrend (90) with no VIX bonus
assert report["metadata"]["market_condition"]["M_score"] == 90
@pytest.mark.parametrize("quote", [{"symbol": "^GSPC", "price": None}, {}])
def test_sp500_quote_without_price_fails_closed(self, monkeypatch, tmp_path, capsys, quote):
# Regression: main() indexed market_data["sp500_price"], which the error dict
# omits, so a ^GSPC quote without a price crashed with KeyError. Like a missing
# quote, it must stop with exit 1 and publish nothing, not rank candidates on
# a fabricated neutral M score.
market = default_market()
market["quotes"]["^GSPC"] = [quote]
with pytest.raises(SystemExit) as exc:
run_main(
monkeypatch,
["--universe", "AAA", "--output-dir", str(tmp_path)],
FakeFMP({"AAA": strong_stock("AAA")}, market=market),
)
assert exc.value.code == 1
assert "ERROR: Unable to determine market direction" in capsys.readouterr().err
assert list(tmp_path.iterdir()) == []
def test_api_key_flag_is_forwarded_to_client(self, monkeypatch, tmp_path):
seen = {}
def factory(api_key=None):
seen["api_key"] = api_key
return FakeFMP({})
monkeypatch.setattr(sys, "argv", ["screen_canslim.py", "--api-key", "cli-key", "--max-candidates", "1", "--output-dir", str(tmp_path)]) # fmt: skip # pragma: allowlist secret
monkeypatch.setattr(sc, "FMPClient", factory)
sc.main()
assert seen["api_key"] == "cli-key" # pragma: allowlist secret
def test_script_guard_runs_main(self, monkeypatch, tmp_path):
"""Executing the file as __main__ without a key exits 1 via main()."""
monkeypatch.delenv("FMP_API_KEY", raising=False)
monkeypatch.setattr(sys, "argv", ["screen_canslim.py", "--output-dir", str(tmp_path)])
with pytest.raises(SystemExit) as exc:
runpy.run_path(os.path.abspath(sc.__file__), run_name="__main__")
assert exc.value.code == 1
scripts/tests/test_canslim_fixes.py
#!/usr/bin/env python3
"""
Tests for CANSLIM screener bug fixes.
Covers:
- B1: M component EMA calculation (should use real historical data, not fallback)
- B2: I component condition ordering (extreme < 10% should score 20, not 40)
- B3: I component mktCap key (FMP API returns "mktCap", not "marketCap")
- C1: report_generator --top parameter (should respect len(results), not hardcode 20)
"""
import os
import tempfile
# Parent scripts directory (for screen_canslim.py path in file-reading tests)
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..")
from calculators.institutional_calculator import (
calculate_institutional_sponsorship,
score_institutional_sponsorship,
)
from calculators.market_calculator import calculate_ema, calculate_market_direction
from report_generator import format_stock_entry, generate_markdown_report
from scorer import WEIGHTS_PHASE2
def test_phase2_weights_sum_to_one():
assert sum(WEIGHTS_PHASE2.values()) == 1.0
def test_stock_entry_handles_missing_price_and_market_cap():
lines = format_stock_entry(
1,
{
"symbol": "NULLS",
"company_name": "Null Values Inc.",
"composite_score": 50.0,
"rating": "Hold",
"rating_description": "Neutral",
"guidance": "Wait",
"weakest_component": "M",
"weakest_score": 50,
"price": None,
"market_cap": None,
},
)
assert "**Price:** N/A | **Market Cap:** N/A" in lines[1]
# ---------------------------------------------------------------------------
# B1: M Component - EMA calculation with real historical data
# ---------------------------------------------------------------------------
class TestMComponentEMA:
"""Verify that calculate_market_direction uses real historical prices
instead of the sp500_price * 0.98 fallback."""
@staticmethod
def _generate_declining_prices(start_price, days, daily_decline_pct=0.15):
"""Generate a list of declining daily prices (most recent first)."""
prices = []
price = start_price
for i in range(days):
prices.append(
{
"date": f"2025-01-{days - i:02d}",
"close": round(price, 2),
"open": round(price + 1, 2),
"high": round(price + 2, 2),
"low": round(price - 1, 2),
"volume": 3_000_000_000,
}
)
price *= 1 - daily_decline_pct / 100
return prices
@staticmethod
def _generate_rising_prices(start_price, days, daily_rise_pct=0.15):
"""Generate a list of rising daily prices (most recent first)."""
prices = []
price = start_price
for i in range(days):
prices.append(
{
"date": f"2025-01-{days - i:02d}",
"close": round(price, 2),
"open": round(price - 1, 2),
"high": round(price + 1, 2),
"low": round(price - 2, 2),
"volume": 3_000_000_000,
}
)
price *= 1 + daily_rise_pct / 100
return prices
def test_ema_calculation_with_sufficient_data(self):
"""EMA should be computed from historical data when >= 50 data points."""
# 60 days of constant price = 100 -> EMA should be ~100
prices = [100.0] * 60
ema = calculate_ema(prices, period=50)
assert abs(ema - 100.0) < 0.01, f"EMA of constant prices should be ~100, got {ema}"
def test_bear_market_detection_with_historical_data(self):
"""When S&P 500 is well below its 50-EMA, M score should be 0-20."""
# Build 60 days of prices that were high (~5000) and then crashed
# Prices declined steadily so EMA remains high relative to current price
historical = []
price = 5000.0
for _i in range(60):
historical.append({"close": round(price, 2)})
price *= 0.995 # ~0.5% daily decline
# Current price is the most recent (lowest) ~3700
current_price = historical[0]["close"] # most recent = lowest after decline
# Actually with 0.5% daily decline over 60 days: 5000 * 0.995^60 ≈ 3700
sp500_quote = {"price": current_price}
result = calculate_market_direction(
sp500_quote=sp500_quote,
sp500_prices=historical,
vix_quote={"price": 32.0}, # High VIX = panic
)
assert result["score"] == 0, (
f"Bear market (price well below 50-EMA) should score 0, got {result['score']}. "
f"distance_from_ema_pct={result.get('distance_from_ema_pct')}"
)
def test_missing_history_returns_neutral_unknown(self):
"""Without historical data the 50-day EMA cannot be computed, so the M
component must return a neutral/unknown state rather than fabricating a
-2% EMA offset that always trips 'strong_uptrend' (which would invert the
component's defensive purpose during a data outage)."""
sp500_quote = {"price": 5000.0}
result = calculate_market_direction(
sp500_quote=sp500_quote,
sp500_prices=None, # No historical data -> cannot compute EMA
vix_quote={"price": 14.0},
)
assert result["score"] == 50, (
f"Missing history should score neutral 50, got {result['score']}"
)
assert result["trend"] == "unknown"
assert result["sp500_ema_50"] is None
assert "error" in result
def test_real_ema_differs_from_fallback(self):
"""With real declining prices, EMA should differ from the naive 0.98 fallback."""
# Build prices that start at 5500 and decline to 5000
historical = []
price = 5500.0
for _i in range(60):
historical.append({"close": round(price, 2)})
price -= 8.33 # Decline from 5500 to ~5000
current_price = historical[0]["close"] # Most recent = 5500
sp500_quote = {"price": current_price}
# With real data
result_real = calculate_market_direction(
sp500_quote=sp500_quote,
sp500_prices=historical,
)
# Without real data (fallback)
result_fallback = calculate_market_direction(
sp500_quote=sp500_quote,
sp500_prices=None,
)
# The EMA values should be different
real_ema = result_real.get("sp500_ema_50")
fallback_ema = result_fallback.get("sp500_ema_50")
assert real_ema != fallback_ema, (
f"Real EMA ({real_ema}) should differ from fallback ({fallback_ema})"
)
# ---------------------------------------------------------------------------
# B2: I Component - Condition ordering in score_institutional_sponsorship
# ---------------------------------------------------------------------------
class TestIComponentConditionOrdering:
"""Verify that extreme ownership ranges (<10% or >90%) score 20,
not 40 (which would happen if the <20% check catches them first)."""
def test_extreme_low_ownership_scores_20(self):
"""ownership_pct=5 should score 20 (extreme), not 40 (suboptimal)."""
score = score_institutional_sponsorship(
num_holders=10,
ownership_pct=5.0,
superinvestor_present=False,
quality_warning=None,
)
assert score == 20, f"Extreme low ownership (5%) should score 20, got {score}"
def test_extreme_high_ownership_scores_20(self):
"""ownership_pct=95 should score 20 (extreme), not 40 (suboptimal)."""
score = score_institutional_sponsorship(
num_holders=200,
ownership_pct=95.0,
superinvestor_present=False,
quality_warning=None,
)
assert score == 20, f"Extreme high ownership (95%) should score 20, got {score}"
def test_suboptimal_ownership_scores_40(self):
"""ownership_pct=15 should score 40 (suboptimal, between 10-20%)."""
score = score_institutional_sponsorship(
num_holders=10,
ownership_pct=15.0,
superinvestor_present=False,
quality_warning=None,
)
assert score == 40, f"Suboptimal ownership (15%) should score 40, got {score}"
def test_suboptimal_high_ownership_scores_40(self):
"""ownership_pct=85 should score 40 (suboptimal, between 80-90%)."""
score = score_institutional_sponsorship(
num_holders=200,
ownership_pct=85.0,
superinvestor_present=False,
quality_warning=None,
)
assert score == 40, f"Suboptimal high ownership (85%) should score 40, got {score}"
def test_boundary_10pct_is_extreme(self):
"""ownership_pct=9.99 should score 20 (extreme: < 10)."""
score = score_institutional_sponsorship(
num_holders=10,
ownership_pct=9.99,
superinvestor_present=False,
quality_warning=None,
)
assert score == 20, f"Boundary (9.99%) should score 20, got {score}"
def test_boundary_90pct_is_extreme(self):
"""ownership_pct=90.01 should score 20 (extreme: > 90)."""
score = score_institutional_sponsorship(
num_holders=200,
ownership_pct=90.01,
superinvestor_present=False,
quality_warning=None,
)
assert score == 20, f"Boundary (90.01%) should score 20, got {score}"
# ---------------------------------------------------------------------------
# B3: I Component - mktCap key from FMP profile
# ---------------------------------------------------------------------------
class TestIComponentMktCapKey:
"""Verify that institutional_calculator handles both 'mktCap' and 'marketCap' keys."""
def _make_holders(self, count, shares_each=1_000_000):
"""Create a list of dummy institutional holders."""
return [
{
"holder": f"Institution {i}",
"shares": shares_each,
"dateReported": "2025-01-01",
"change": 0,
}
for i in range(count)
]
def test_mktCap_key_calculates_shares_outstanding(self):
"""Profile with 'mktCap' (FMP format) should calculate shares_outstanding correctly."""
holders = self._make_holders(60, shares_each=500_000)
profile = {"mktCap": 1_000_000_000, "price": 100.0} # 1B / 100 = 10M shares
result = calculate_institutional_sponsorship(
institutional_holders=holders,
profile=profile,
symbol="TEST",
use_finviz_fallback=False,
)
# shares_outstanding = mktCap / price = 1e9 / 100 = 10,000,000
assert result["shares_outstanding"] == 10_000_000, (
f"Expected shares_outstanding=10M, got {result['shares_outstanding']}"
)
# ownership_pct = (60 * 500_000) / 10_000_000 * 100 = 300%
# This is extreme but the point is that shares_outstanding is calculated
assert result["ownership_pct"] is not None, (
"ownership_pct should be calculated when mktCap is available"
)
def test_marketCap_key_also_works(self):
"""Profile with 'marketCap' (alternative format) should also work."""
holders = self._make_holders(60, shares_each=500_000)
profile = {"marketCap": 2_000_000_000, "price": 200.0} # 2B / 200 = 10M shares
result = calculate_institutional_sponsorship(
institutional_holders=holders,
profile=profile,
symbol="TEST",
use_finviz_fallback=False,
)
assert result["shares_outstanding"] == 10_000_000, (
f"Expected shares_outstanding=10M with marketCap key, got {result['shares_outstanding']}"
)
def test_sharesOutstanding_takes_priority(self):
"""If profile has sharesOutstanding directly, it should be used first."""
holders = self._make_holders(60, shares_each=500_000)
profile = {
"sharesOutstanding": 5_000_000,
"mktCap": 1_000_000_000,
"price": 100.0,
}
result = calculate_institutional_sponsorship(
institutional_holders=holders,
profile=profile,
symbol="TEST",
use_finviz_fallback=False,
)
assert result["shares_outstanding"] == 5_000_000, (
f"sharesOutstanding should take priority, got {result['shares_outstanding']}"
)
# ---------------------------------------------------------------------------
# C1: report_generator --top parameter
# ---------------------------------------------------------------------------
class TestReportGeneratorTopParameter:
"""Verify that generate_markdown_report outputs all results, not just 20."""
@staticmethod
def _make_stock(rank):
"""Create a minimal stock result dict."""
return {
"symbol": f"SYM{rank:02d}",
"company_name": f"Company {rank}",
"price": 100.0 + rank,
"market_cap": 1_000_000_000 * rank,
"sector": "Technology",
"composite_score": 90.0 - rank * 0.5,
"rating": "Strong",
"rating_description": "Solid across all components",
"guidance": "Buy",
"weakest_component": "M",
"weakest_score": 70,
"c_component": {"score": 80},
"a_component": {"score": 75},
"n_component": {"score": 70},
"s_component": {"score": 65},
"l_component": {"score": 60},
"i_component": {"score": 55},
"m_component": {"score": 70, "trend": "uptrend"},
}
def test_30_results_all_appear_in_report(self):
"""When 30 results are passed, all 30 should appear in the Markdown report."""
results = [self._make_stock(i) for i in range(1, 31)]
metadata = {
"generated_at": "2025-01-01 00:00:00 UTC",
"phase": "3 (7 components - FULL CANSLIM)",
"components_included": ["C", "A", "N", "S", "L", "I", "M"],
"candidates_analyzed": 30,
"market_condition": {
"trend": "uptrend",
"M_score": 80,
"warning": None,
},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
output_file = f.name
try:
generate_markdown_report(results, metadata, output_file)
with open(output_file, encoding="utf-8") as f:
content = f.read()
# Check that the header says "Top 30" not "Top 20"
assert "Top 30" in content, (
"Report header should say 'Top 30', found: "
+ [line for line in content.split("\n") if "Top" in line and "CANSLIM" in line][0]
)
# Check that SYM30 (the 30th stock) appears in the report
assert "SYM30" in content, (
"30th stock (SYM30) should appear in the report but was not found"
)
# Count the number of stock entries (each starts with "### N.")
import re
stock_entries = re.findall(r"^### \d+\.", content, re.MULTILINE)
assert len(stock_entries) == 30, (
f"Expected 30 stock entries, found {len(stock_entries)}"
)
finally:
os.unlink(output_file)
def test_5_results_shows_5(self):
"""When only 5 results, report should show 5 not truncate."""
results = [self._make_stock(i) for i in range(1, 6)]
metadata = {
"generated_at": "2025-01-01 00:00:00 UTC",
"phase": "3 (7 components - FULL CANSLIM)",
"components_included": ["C", "A", "N", "S", "L", "I", "M"],
"candidates_analyzed": 5,
"market_condition": {"trend": "uptrend", "M_score": 80, "warning": None},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
output_file = f.name
try:
generate_markdown_report(results, metadata, output_file)
with open(output_file, encoding="utf-8") as f:
content = f.read()
assert "Top 5" in content, "Report header should say 'Top 5'"
import re
stock_entries = re.findall(r"^### \d+\.", content, re.MULTILINE)
assert len(stock_entries) == 5, f"Expected 5 stock entries, found {len(stock_entries)}"
finally:
os.unlink(output_file)
# ---------------------------------------------------------------------------
# Integration: ^GSPC/SPY scale mismatch detection
# ---------------------------------------------------------------------------
class TestBenchmarkScaleConsistency:
"""Verify that M component rejects or handles mixed price scales.
The ^GSPC index trades at ~5000 while the SPY ETF trades at ~500.
If the quote comes from ^GSPC and historical data comes from SPY,
the EMA will be ~500 and distance_from_ema_pct will be ~+900%,
giving a falsely bullish M score.
"""
def test_mixed_scale_produces_extreme_distance(self):
"""Demonstrate the bug: ^GSPC quote + SPY historical = +900% distance."""
# Simulated ^GSPC quote (index level ~5000)
gspc_quote = {"price": 5000.0}
# Simulated SPY historical (ETF level ~500)
spy_historical = [{"close": 500.0 + i * 0.1} for i in range(60)]
result = calculate_market_direction(
sp500_quote=gspc_quote,
sp500_prices=spy_historical,
vix_quote={"price": 15.0},
)
# This demonstrates the scale mismatch problem:
# EMA(~500) vs price(5000) → distance ≈ +900%
# Any distance > 20% is unreasonable and indicates a data problem
distance = result.get("distance_from_ema_pct", 0)
assert abs(distance) > 100, (
f"Mixed ^GSPC/SPY scales should produce extreme distance, got {distance:.1f}%"
)
# This will always be "strong_uptrend" with score 90-100 - clearly wrong
assert result["score"] >= 90, (
f"Mixed scales should produce falsely bullish score (>=90), got {result['score']}"
)
def test_same_scale_gspc_produces_reasonable_distance(self):
"""With ^GSPC for both quote and historical, distance is reasonable."""
# Both at ^GSPC scale (~5000)
gspc_quote = {"price": 5100.0}
# Slight uptrend: prices from 4900 to 5100 over 60 days
gspc_historical = []
for i in range(60):
price = 4900.0 + (200.0 / 60) * i
gspc_historical.append({"close": round(price, 2)})
result = calculate_market_direction(
sp500_quote=gspc_quote,
sp500_prices=gspc_historical,
vix_quote={"price": 15.0},
)
distance = result.get("distance_from_ema_pct", 0)
# With same-scale data, distance should be modest (< 20%)
assert abs(distance) < 20, (
f"Same-scale ^GSPC data should produce reasonable distance (<20%), got {distance:.1f}%"
)
def test_same_scale_spy_produces_reasonable_distance(self):
"""With SPY for both quote and historical, distance is also reasonable."""
# Both at SPY scale (~500)
spy_quote = {"price": 510.0}
spy_historical = []
for i in range(60):
price = 490.0 + (20.0 / 60) * i
spy_historical.append({"close": round(price, 2)})
result = calculate_market_direction(
sp500_quote=spy_quote,
sp500_prices=spy_historical,
vix_quote={"price": 15.0},
)
distance = result.get("distance_from_ema_pct", 0)
assert abs(distance) < 20, (
f"Same-scale SPY data should produce reasonable distance (<20%), got {distance:.1f}%"
)
def test_screen_canslim_uses_gspc_for_historical(self):
"""Verify that screen_canslim.py uses ^GSPC (not SPY) for historical data."""
import re
screen_file = os.path.join(SCRIPTS_DIR, "screen_canslim.py")
with open(screen_file, encoding="utf-8") as f:
content = f.read()
# Find all get_historical_prices calls for S&P 500 / market data
hist_calls = re.findall(
r'client\.get_historical_prices\(\s*["\']([^"\']+)["\']',
content,
)
# Find the quote call for S&P 500
quote_calls = re.findall(
r'client\.get_quote\(\s*["\'](\^GSPC[^"\']*)["\']',
content,
)
assert len(quote_calls) >= 1, "Should have at least one ^GSPC quote call"
# The historical call for market data should use ^GSPC, not SPY
market_hist_tickers = [t for t in hist_calls if t in ("^GSPC", "SPY")]
assert "^GSPC" in market_hist_tickers, (
f"Historical prices for market data should use ^GSPC, "
f"found tickers: {market_hist_tickers}"
)
assert "SPY" not in market_hist_tickers, (
f"Historical prices should NOT use SPY (scale mismatch with ^GSPC quote), "
f"found tickers: {market_hist_tickers}"
)
scripts/tests/test_fmp_fallback.py
#!/usr/bin/env python3
"""
Tests for FMP stable/v3 endpoint fallback in canslim-screener.
Tier A (4): Fallback logic
Tier B (4): Response normalization
Tier B+ (2): Shape validation
Caller regression: screen_canslim.py behavior on failure/fallback
"""
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _make_client():
"""Create FMPClient with a fake API key."""
with patch.dict(os.environ, {"FMP_API_KEY": "test_key"}): # pragma: allowlist secret
from fmp_client import FMPClient
client = FMPClient(api_key="test_key")
return client
def _mock_response(status_code=200, json_data=None, text=""):
"""Create a mock requests.Response."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_data
resp.text = text
return resp
# ---------------------------------------------------------------------------
# Tier A — Fallback logic (4 tests)
# ---------------------------------------------------------------------------
class TestFallbackLogic:
"""Verify stable-first, v3-fallback behavior."""
def test_quote_stable_success(self):
"""Stable 200 returns data; v3 is never called."""
client = _make_client()
stable_resp = _mock_response(200, [{"symbol": "^GSPC", "price": 5000}])
call_count = {"n": 0}
def fake_get(url, params=None, timeout=30):
call_count["n"] += 1
if "stable" in url:
return stable_resp
pytest.fail("v3 endpoint should not be called")
client.session.get = fake_get
result = client.get_quote("^GSPC")
assert result == [{"symbol": "^GSPC", "price": 5000}]
assert call_count["n"] == 1
def test_quote_stable_403_falls_back_to_v3(self):
"""Stable 403 → v3 200 → returns v3 data."""
client = _make_client()
stable_resp = _mock_response(403, None, "Forbidden")
v3_resp = _mock_response(200, [{"symbol": "^GSPC", "price": 5100}])
def fake_get(url, params=None, timeout=30):
if "stable" in url:
return stable_resp
return v3_resp
client.session.get = fake_get
result = client.get_quote("^GSPC")
assert result == [{"symbol": "^GSPC", "price": 5100}]
def test_quote_both_fail(self):
"""Both endpoints 403 → returns None."""
client = _make_client()
resp_403 = _mock_response(403, None, "Forbidden")
client.session.get = MagicMock(return_value=resp_403)
result = client.get_quote("^GSPC")
assert result is None
def test_historical_fallback_to_v3(self):
"""Stable 403 → v3 200 → returns v3 historical data."""
client = _make_client()
stable_resp = _mock_response(403, None, "Forbidden")
v3_data = {"symbol": "^GSPC", "historical": [{"date": "2026-03-20", "close": 5000}]}
v3_resp = _mock_response(200, v3_data)
def fake_get(url, params=None, timeout=30):
if "stable" in url:
return stable_resp
return v3_resp
client.session.get = fake_get
result = client.get_historical_prices("^GSPC", days=80)
assert result is not None
assert "historical" in result
assert result["historical"][0]["close"] == 5000
# ---------------------------------------------------------------------------
# Tier B — Response normalization (4 tests)
# ---------------------------------------------------------------------------
class TestResponseNormalization:
"""Verify response shape handling for stable vs v3 formats."""
def test_historical_stable_v3_format_passthrough(self):
"""Stable returns v3-like {"historical": [...]} → returned as-is."""
client = _make_client()
data = {"symbol": "^GSPC", "historical": [{"date": "2026-03-20", "close": 5000}]}
resp = _mock_response(200, data)
client.session.get = MagicMock(return_value=resp)
result = client.get_historical_prices("^GSPC", days=80)
assert result == data
def test_historical_stable_batch_format_exact_match(self):
"""Stable returns historicalStockList with matching symbol → normalized."""
client = _make_client()
batch_data = {
"historicalStockList": [
{
"symbol": "^GSPC",
"historical": [{"date": "2026-03-20", "close": 5000}],
}
]
}
resp = _mock_response(200, batch_data)
client.session.get = MagicMock(return_value=resp)
result = client.get_historical_prices("^GSPC", days=80)
assert result is not None
assert result["symbol"] == "^GSPC"
assert result["historical"] == [{"date": "2026-03-20", "close": 5000}]
def test_historical_stable_batch_no_match_falls_back_to_v3(self):
"""Stable batch has wrong symbol → continue to v3 → v3 200."""
client = _make_client()
batch_data = {"historicalStockList": [{"symbol": "SPY", "historical": [{"close": 500}]}]}
stable_resp = _mock_response(200, batch_data)
v3_data = {"symbol": "^GSPC", "historical": [{"close": 5000}]}
v3_resp = _mock_response(200, v3_data)
def fake_get(url, params=None, timeout=30):
if "stable" in url:
return stable_resp
return v3_resp
client.session.get = fake_get
result = client.get_historical_prices("^GSPC", days=80)
assert result is not None
assert result["historical"][0]["close"] == 5000
def test_historical_batch_no_match_returns_none_when_v3_also_fails(self):
"""Stable batch no match + v3 403 → returns None."""
client = _make_client()
batch_data = {"historicalStockList": [{"symbol": "SPY", "historical": [{"close": 500}]}]}
stable_resp = _mock_response(200, batch_data)
v3_resp = _mock_response(403, None, "Forbidden")
def fake_get(url, params=None, timeout=30):
if "stable" in url:
return stable_resp
return v3_resp
client.session.get = fake_get
result = client.get_historical_prices("^GSPC", days=80)
assert result is None
# ---------------------------------------------------------------------------
# Tier B+ — Shape validation (2 tests)
# ---------------------------------------------------------------------------
class TestShapeValidation:
"""Reject truthy-but-wrong-shape responses."""
def test_quote_rejects_non_list_response(self):
"""Stable returns truthy dict → skipped, falls back to v3."""
client = _make_client()
error_data = {"Error Message": "Invalid API KEY"}
stable_resp = _mock_response(200, error_data)
v3_data = [{"symbol": "^GSPC", "price": 5000}]
v3_resp = _mock_response(200, v3_data)
def fake_get(url, params=None, timeout=30):
if "stable" in url:
return stable_resp
return v3_resp
client.session.get = fake_get
result = client.get_quote("^GSPC")
assert result == v3_data
def test_historical_rejects_non_dict_response(self):
"""Stable returns truthy list → skipped, falls back to v3."""
client = _make_client()
stable_resp = _mock_response(200, [1, 2, 3])
v3_data = {"symbol": "^GSPC", "historical": [{"close": 5000}]}
v3_resp = _mock_response(200, v3_data)
def fake_get(url, params=None, timeout=30):
if "stable" in url:
return stable_resp
return v3_resp
client.session.get = fake_get
result = client.get_historical_prices("^GSPC", days=80)
assert result == v3_data
# ---------------------------------------------------------------------------
# Symbol mismatch protection (3 tests)
# ---------------------------------------------------------------------------
class TestSymbolMismatch:
"""Reject responses where returned symbol doesn't match the request."""
def test_quote_symbol_mismatch_falls_back(self):
"""Single-symbol quote returning wrong symbol is rejected."""
client = _make_client()
wrong = _mock_response(200, [{"symbol": "SPY", "price": 500.0}])
correct = _mock_response(200, [{"symbol": "^GSPC", "price": 5000.0}])
client.session.get = MagicMock(side_effect=[wrong, correct])
result = client.get_quote("^GSPC")
assert result == [{"symbol": "^GSPC", "price": 5000.0}]
assert client.session.get.call_count == 2
def test_historical_symbol_mismatch_falls_back(self):
"""Single-symbol historical returning wrong symbol is rejected."""
client = _make_client()
wrong = _mock_response(200, {"symbol": "SPY", "historical": [{"close": 500}]})
correct = _mock_response(200, {"symbol": "^GSPC", "historical": [{"close": 5000}]})
client.session.get = MagicMock(side_effect=[wrong, correct])
result = client.get_historical_prices("^GSPC", days=80)
assert result["symbol"] == "^GSPC"
assert client.session.get.call_count == 2
def test_batch_quote_skips_symbol_check(self):
"""Multi-symbol (batch) quote does not apply symbol mismatch check."""
client = _make_client()
batch_data = [{"symbol": "^GSPC", "price": 5000}, {"symbol": "^VIX", "price": 20}]
resp = _mock_response(200, batch_data)
client.session.get = MagicMock(return_value=resp)
result = client.get_quote("^GSPC,^VIX")
assert result == batch_data
assert client.session.get.call_count == 1
# ---------------------------------------------------------------------------
# Caller regression
# ---------------------------------------------------------------------------
class TestCallerRegression:
"""Verify screen_canslim.py behavior when FMP endpoints fail."""
@pytest.mark.parametrize("institutional_holders", [None, []])
def test_analyze_stock_calls_institutional_calculator_when_holders_missing(
self, institutional_holders
):
"""Missing FMP holders must still reach the Finviz-capable calculator."""
import screen_canslim
profile = {
"companyName": "Test Corp",
"sector": "Technology",
"mktCap": 200_000_000_000,
"price": 200.0,
"sharesOutstanding": 1_000_000_000,
}
client = MagicMock()
client.get_profile.return_value = [profile]
client.get_quote.return_value = [
{
"symbol": "TEST",
"price": 200.0,
"yearHigh": 210.0,
"yearLow": 120.0,
"volume": 2_000_000,
"avgVolume": 1_000_000,
}
]
client.get_income_statement.return_value = [
{"eps": 1.0, "revenue": 100_000_000, "date": "2026-03-31"}
] * 8
client.get_historical_prices.return_value = {
"historical": [
{
"date": f"2026-01-{(i % 28) + 1:02d}",
"close": 100.0 + ((-1) ** i),
"volume": 1_000_000 + (i * 1_000),
}
for i in range(90)
]
}
client.get_institutional_holders.return_value = institutional_holders
market_data = {"score": 80, "trend": "uptrend"}
with patch.object(
screen_canslim,
"calculate_institutional_sponsorship",
return_value={"score": 50, "data_source": "Finviz"},
) as mock_calculator:
result = screen_canslim.analyze_stock(
"TEST",
client,
market_data,
rs_benchmark_historical=None,
rs_benchmark="^GSPC",
disable_rs=True,
)
assert result["i_component"]["data_source"] == "Finviz"
mock_calculator.assert_called_once_with(
institutional_holders, profile, symbol="TEST", use_finviz_fallback=True
)
def test_canslim_exits_on_quote_failure(self):
"""get_quote("^GSPC") → None causes sys.exit(1)."""
with patch.dict(os.environ, {"FMP_API_KEY": "test_key"}): # pragma: allowlist secret
from fmp_client import FMPClient
with patch.object(FMPClient, "get_quote", return_value=None):
with patch("sys.argv", ["screen_canslim.py", "--max-candidates", "1"]):
import screen_canslim
with pytest.raises(SystemExit) as exc_info:
screen_canslim.main()
assert exc_info.value.code == 1
def test_canslim_continues_on_historical_failure(self, capsys, tmp_path):
"""get_historical_prices("^GSPC") → None prints EMA fallback warning and continues."""
with patch.dict(os.environ, {"FMP_API_KEY": "test_key"}): # pragma: allowlist secret
from fmp_client import FMPClient
mock_quote = [
{
"symbol": "^GSPC",
"price": 5000.0,
"yearHigh": 5200.0,
"yearLow": 4200.0,
"changesPercentage": 0.5,
}
]
mock_vix = [{"symbol": "^VIX", "price": 15.0}]
def mock_get_quote(symbols):
if "^GSPC" in symbols and "^VIX" not in symbols:
return mock_quote
if "^VIX" in symbols:
return mock_vix
return mock_quote
with (
patch.object(FMPClient, "get_quote", side_effect=mock_get_quote),
patch.object(FMPClient, "get_historical_prices", return_value=None),
patch.object(FMPClient, "get_income_statement", return_value=None),
patch.object(FMPClient, "get_profile", return_value=None),
patch.object(FMPClient, "get_institutional_holders", return_value=None),
patch(
"sys.argv",
[
"screen_canslim.py",
"--max-candidates",
"1",
"--universe",
"AAPL",
"--output-dir",
str(tmp_path),
],
),
):
import screen_canslim
# Should NOT raise SystemExit — historical failure is non-fatal
try:
screen_canslim.main()
except SystemExit:
pytest.fail("screen_canslim.main() should not exit when historical prices fail")
captured = capsys.readouterr()
assert "EMA fallback" in captured.out or "historical data unavailable" in captured.out
class TestEODFlatListSuccess:
"""Issue #64: stable EOD flat list -> public method success (regression)."""
@patch("fmp_client.requests.Session")
def test_get_historical_prices_normalizes_flat_list(self, mock_session_class):
"""Flat list response from new EOD endpoint -> dict contract preserved."""
mock_session = MagicMock()
mock_session.get.return_value = _mock_response(
200,
[
{
"symbol": "SPY",
"date": "2026-04-29",
"open": 500.0,
"high": 502.0,
"low": 499.0,
"close": 501.0,
"volume": 1_000_000,
},
{
"symbol": "SPY",
"date": "2026-04-28",
"open": 498.0,
"high": 501.0,
"low": 497.0,
"close": 500.0,
"volume": 1_100_000,
},
],
)
mock_session_class.return_value = mock_session
client = _make_client()
client.session = mock_session
client.max_retries = 0
result = client.get_historical_prices("SPY", days=2)
assert isinstance(result, dict), f"expected dict, got {type(result).__name__}"
assert result["symbol"] == "SPY"
assert len(result["historical"]) == 2
assert result["historical"][0]["close"] == 501.0
# URL regression: must hit /historical-price-eod/full with from/to (not timeseries)
first_call = mock_session.get.call_args_list[0]
url = first_call[0][0]
params = first_call[1]["params"]
assert "historical-price-eod/full" in url
assert "from" in params and "to" in params
assert "timeseries" not in params
scripts/tests/test_fmp_stable_migration.py
"""FMP /stable migration for canslim-screener.
Keys issued after 2025-08-31 lose v3 access (403). Three FMP calls are migrated
(stable-first, v3 fallback):
- get_income_statement: /income-statement/{sym} -> /stable/income-statement?symbol=&period=
- get_profile: /profile/{sym} -> /stable/profile?symbol= (+ mktCap alias)
- get_institutional_holders: /institutional-holder/{sym} (full list) ->
/stable institutional-ownership summary (count + ownership%) + top-holders
page (superinvestor names), returned as an aggregate dict.
"""
import os
from datetime import date
from unittest.mock import MagicMock, patch
def _make_client():
with patch.dict(os.environ, {"FMP_API_KEY": "test_key"}): # pragma: allowlist secret
from fmp_client import FMPClient
client = FMPClient(api_key="test_key")
client.RATE_LIMIT_DELAY = 0 # no sleep in tests
return client
def _resp(status_code=200, json_data=None):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_data
resp.text = ""
return resp
class TestIncomeStatementProfile:
def test_income_statement_uses_stable_query(self):
client = _make_client()
captured = {}
def fake_get(url, params=None, timeout=30):
captured["url"], captured["params"] = url, params
return _resp(200, [{"symbol": "AAPL", "period": "Q2", "eps": 2.0}])
client.session.get = fake_get
client.get_income_statement("AAPL", period="quarter", limit=8)
assert captured["url"].endswith("/stable/income-statement")
assert captured["params"]["symbol"] == "AAPL"
assert captured["params"]["period"] == "quarter"
def test_profile_stable_with_mktcap_alias(self):
client = _make_client()
# /stable/profile returns marketCap, not mktCap.
client.session.get = lambda *a, **k: _resp(
200, [{"symbol": "AAPL", "companyName": "Apple", "marketCap": 4_000_000_000_000}]
)
profile = client.get_profile("AAPL")
assert profile[0]["mktCap"] == 4_000_000_000_000 # aliased for downstream
class TestInstitutionalHolders:
def test_summary_plus_top_holders(self):
def fake_get(url, params=None, timeout=30):
if "symbol-positions-summary" in url:
return _resp(200, [{"investorsHolding": 6170, "ownershipPercent": 61.6}])
if "extract-analytics/holder" in url:
return _resp(
200,
[
{
"investorName": "VANGUARD GROUP INC",
"sharesNumber": 100,
"changeInSharesNumber": 5,
},
{
"investorName": "BLACKROCK, INC.",
"sharesNumber": 90,
"changeInSharesNumber": -2,
},
],
)
raise AssertionError(f"unexpected {url}")
client = _make_client()
client.session.get = fake_get
result = client.get_institutional_holders("AAPL")
assert result["num_holders"] == 6170
assert result["ownership_pct"] == 61.6
assert result["top_holders"][0] == {
"holder": "VANGUARD GROUP INC",
"shares": 100,
"change": 5,
}
def test_v3_fallback_when_stable_empty(self):
def fake_get(url, params=None, timeout=30):
if "/stable/" in url:
return _resp(200, []) # no stable 13F data for any quarter
# v3 institutional-holder full list
return _resp(200, [{"holder": "Vanguard", "shares": 100, "change": 1}] * 75)
client = _make_client()
client.session.get = fake_get
result = client.get_institutional_holders("AAPL")
assert result["num_holders"] == 75 # len of the v3 list
assert result["ownership_pct"] is None # calculator derives it
def test_recent_13f_quarters_walk_back(self):
from fmp_client import FMPClient
# May 2026 -> most recent completed quarter is Q1 2026, then walk back.
quarters = list(FMPClient._recent_13f_quarters(as_of=date(2026, 5, 20), count=3))
assert quarters == [(2026, 1), (2025, 4), (2025, 3)]
class TestInstitutionalCalculatorDictShape:
def test_uses_aggregate_count_and_ownership(self):
from calculators.institutional_calculator import calculate_institutional_sponsorship
agg = {
"num_holders": 6170,
"ownership_pct": 61.6,
# SUPERINVESTORS are famous active managers (Berkshire, Baupost, ...),
# not passive index funds, so this name triggers the bonus.
"top_holders": [
{"holder": "BLACKROCK, INC.", "shares": 90, "change": 1},
{"holder": "BERKSHIRE HATHAWAY INC", "shares": 50, "change": 0},
],
}
result = calculate_institutional_sponsorship(agg, profile={}, use_finviz_fallback=False)
assert result["num_holders"] == 6170
assert result["ownership_pct"] == 61.6
assert result["superinvestor_present"] is True # matched Berkshire
assert result["score"] > 0
scripts/tests/test_institutional_fallback.py
"""Regression test for institutional 'I' component Finviz fallback.
Without this fix, FMP free-tier users (no /stable institutional-ownership
access, and /api/v3 deprecated since 2025-08-31) get score=0 from the
'I' component for every stock, even when Finviz can supply real data.
The bug had two layers:
1. Early-return on falsy `institutional_holders` skipped the Finviz path.
2. When the holder list was empty but a profile existed, the formula
`0 / shares_outstanding * 100 = 0.0` produced a non-None ownership_pct
that blocked the `ownership_pct is None` Finviz gate.
This test exercises both paths with a mocked Finviz client so it doesn't
need network access.
"""
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
# Ensure imports work in the test runner.
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE.parent))
sys.path.insert(0, str(HERE.parent / "calculators"))
from institutional_calculator import calculate_institutional_sponsorship # noqa: E402
def _fake_finviz(_self, _symbol):
return {"inst_own_pct": 67.5, "inst_trans_pct": 0.0, "error": None}
def test_finviz_fallback_runs_when_fmp_holders_none():
"""holders=None should not short-circuit to score=0 — Finviz should fill in."""
profile = {"sharesOutstanding": 15_000_000_000, "mktCap": 3_000_000_000_000, "price": 200.0}
with (
patch("institutional_calculator.FINVIZ_AVAILABLE", True),
patch("finviz_stock_client.FinvizStockClient.get_institutional_ownership", _fake_finviz),
):
result = calculate_institutional_sponsorship(None, profile, symbol="AAPL")
assert result["score"] > 0, "score should not be 0 — Finviz fallback should fire"
assert result["ownership_pct"] == 67.5
assert "Finviz" in (result.get("quality_warning") or "")
def test_finviz_fallback_runs_when_fmp_holders_empty_list():
"""holders=[] (empty list, not None) should also let Finviz fill in."""
profile = {"sharesOutstanding": 1_000_000_000, "mktCap": 200_000_000_000, "price": 200.0}
with (
patch("institutional_calculator.FINVIZ_AVAILABLE", True),
patch("finviz_stock_client.FinvizStockClient.get_institutional_ownership", _fake_finviz),
):
result = calculate_institutional_sponsorship([], profile, symbol="NVDA")
assert result["score"] > 0
assert result["ownership_pct"] == 67.5
@pytest.mark.parametrize("holders", [None, []])
def test_no_finviz_available_preserves_zero_score_for_true_no_data(holders):
"""When FMP and Finviz both have no data, true no-data inputs score 0."""
profile = {"sharesOutstanding": 1_000_000_000, "mktCap": 200_000_000_000, "price": 200.0}
with patch("institutional_calculator.FINVIZ_AVAILABLE", False):
result = calculate_institutional_sponsorship(holders, profile, symbol="AMD")
assert "score" in result
assert "num_holders" in result
assert result["score"] == 0
# ownership_pct must remain None so callers can detect the gap.
assert result["ownership_pct"] is None
def test_no_finviz_available_does_not_zero_partial_fmp_aggregate():
"""Valid FMP aggregate holder counts still use reduced holder-count scoring."""
holders = {"num_holders": 40, "ownership_pct": None, "top_holders": []}
profile = {"sharesOutstanding": 1_000_000_000, "mktCap": 200_000_000_000, "price": 200.0}
with patch("institutional_calculator.FINVIZ_AVAILABLE", False):
result = calculate_institutional_sponsorship(holders, profile, symbol="AMD")
assert result["score"] == 40
assert result["ownership_pct"] is None
def test_with_valid_fmp_holders_finviz_not_called():
"""When FMP gives real data, the Finviz path must NOT run (no wasted scraping)."""
holders = {
"num_holders": 80,
"ownership_pct": 65.0,
"top_holders": [{"holder": "VANGUARD GROUP", "shares": 1_000_000, "change": 0}],
}
profile = {"sharesOutstanding": 1_000_000_000}
finviz_called = {"hit": False}
def _spy_finviz(_self, _symbol):
finviz_called["hit"] = True
return {"inst_own_pct": 99.9, "error": None}
with (
patch("institutional_calculator.FINVIZ_AVAILABLE", True),
patch("finviz_stock_client.FinvizStockClient.get_institutional_ownership", _spy_finviz),
):
result = calculate_institutional_sponsorship(holders, profile, symbol="AAPL")
assert finviz_called["hit"] is False, "Finviz path must not run when FMP data is present"
assert result["ownership_pct"] == 65.0
scripts/tests/test_leadership_rs.py
#!/usr/bin/env python3
"""
Tests for CANSLIM L component multi-period RS extension (Phase 3.1).
Covers:
- slice_period_return: window slicing & graceful fallback
- compute_multi_period_rs: full / partial / no-benchmark cases
- classify_rs_rating: threshold mapping with boundary values
- calculate_leadership: extended fields, legacy fields preserved, legacy path
- screen_canslim CLI flags: --rs-benchmark / --disable-rs propagation
- report_generator: summary table + multi-period L row
"""
import os
import tempfile
from unittest.mock import MagicMock
from calculators.leadership_calculator import (
DEFAULT_PERIODS_DAYS,
calculate_leadership,
classify_rs_rating,
compute_multi_period_rs,
slice_period_return,
)
from report_generator import generate_markdown_report
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def _make_price_series(start_price: float, daily_pct: float, days: int) -> list[dict]:
"""
Build a simple ascending-date series of {"date", "close"} dicts.
daily_pct: daily compounded return (e.g. 0.001 = +0.1%/day).
"""
series = []
price = start_price
for i in range(days):
# Use a synthetic date that sorts correctly lexicographically.
date_str = f"2024-{(i // 30) + 1:02d}-{(i % 30) + 1:02d}"
series.append({"date": date_str, "close": round(price, 4)})
price *= 1 + daily_pct
return series
# ---------------------------------------------------------------------------
# slice_period_return
# ---------------------------------------------------------------------------
class TestSlicePeriodReturn:
def test_basic_window(self):
# 100 days, +0.1% per day => total ~+10.46%
series = _make_price_series(100.0, 0.001, 100)
ret = slice_period_return(series, 100)
assert ret is not None
assert 10.0 < ret < 10.6
def test_smaller_window(self):
# Take only the most recent 30 days from a 100-day series.
series = _make_price_series(100.0, 0.001, 100)
ret = slice_period_return(series, 30)
assert ret is not None
# 29 compounded days at +0.1% => ~+2.94%
assert 2.5 < ret < 3.5
def test_insufficient_data_returns_none(self):
series = _make_price_series(100.0, 0.001, 30)
assert slice_period_return(series, 60) is None
def test_empty_input(self):
assert slice_period_return([], 30) is None
assert slice_period_return(None, 30) is None
def test_invalid_start_price(self):
bad = [{"date": "2024-01-01", "close": 0}, {"date": "2024-01-02", "close": 100}]
assert slice_period_return(bad, 2) is None
def test_normalizes_descending_input(self):
ascending = _make_price_series(100.0, 0.001, 100)
descending = list(reversed(ascending))
# Both orderings should yield the same return because slice_period_return
# normalizes internally.
asc_ret = slice_period_return(ascending, 50)
desc_ret = slice_period_return(descending, 50)
assert asc_ret is not None and desc_ret is not None
assert abs(asc_ret - desc_ret) < 1e-6
# ---------------------------------------------------------------------------
# compute_multi_period_rs
# ---------------------------------------------------------------------------
class TestComputeMultiPeriodRS:
def test_all_periods_available(self):
stock = _make_price_series(100.0, 0.002, 260) # strong uptrend
bench = _make_price_series(100.0, 0.001, 260) # weaker uptrend
result = compute_multi_period_rs(stock, bench)
assert result["rs_3m_return"] is not None
assert result["rs_6m_return"] is not None
assert result["rs_12m_return"] is not None
assert result["benchmark_3m_return"] is not None
assert result["rel_12m"] > 0 # stock outperforms benchmark
assert result["weighted_relative_performance"] > 0
assert result["weighted_stock_performance"] is not None
assert set(result["available_periods"]) == {"3m", "6m", "12m"}
assert result["missing_periods"] == []
def test_only_3m_and_6m_available(self):
# 200 days < 252 (12m) but >= 126 (6m) and 63 (3m)
stock = _make_price_series(100.0, 0.002, 200)
bench = _make_price_series(100.0, 0.001, 200)
result = compute_multi_period_rs(stock, bench)
assert result["rs_3m_return"] is not None
assert result["rs_6m_return"] is not None
assert result["rs_12m_return"] is None
assert result["weighted_stock_performance"] is not None
assert result["weighted_relative_performance"] is not None
assert "12m" in result["missing_periods"]
assert set(result["available_periods"]) == {"3m", "6m"}
def test_no_benchmark(self):
stock = _make_price_series(100.0, 0.002, 260)
result = compute_multi_period_rs(stock, None)
assert result["rs_3m_return"] is not None
assert result["benchmark_3m_return"] is None
assert result["rel_3m"] is None
assert result["weighted_stock_performance"] is not None
# Without a benchmark, the weighted RELATIVE performance is undefined.
assert result["weighted_relative_performance"] is None
def test_all_missing(self):
# Far less than 63 bars
stock = _make_price_series(100.0, 0.002, 30)
result = compute_multi_period_rs(stock, None)
assert result["weighted_stock_performance"] is None
assert result["weighted_relative_performance"] is None
assert result["available_periods"] == []
# ---------------------------------------------------------------------------
# classify_rs_rating
# ---------------------------------------------------------------------------
class TestClassifyRsRating:
def test_market_leader(self):
assert classify_rs_rating(99) == "Market Leader"
assert classify_rs_rating(90) == "Market Leader"
def test_strong(self):
assert classify_rs_rating(89) == "Strong"
assert classify_rs_rating(80) == "Strong"
def test_above_average(self):
assert classify_rs_rating(79) == "Above Average"
assert classify_rs_rating(60) == "Above Average"
def test_average(self):
assert classify_rs_rating(59) == "Average"
assert classify_rs_rating(40) == "Average"
def test_laggard(self):
assert classify_rs_rating(39) == "Laggard"
assert classify_rs_rating(25) == "Laggard"
def test_weak(self):
assert classify_rs_rating(24) == "Weak"
assert classify_rs_rating(10) == "Weak"
assert classify_rs_rating(0) == "Weak"
def test_none_returns_weak(self):
assert classify_rs_rating(None) == "Weak"
# ---------------------------------------------------------------------------
# calculate_leadership (extended)
# ---------------------------------------------------------------------------
class TestCalculateLeadershipExtended:
def test_legacy_fields_preserved(self):
stock = _make_price_series(100.0, 0.002, 260)
bench = _make_price_series(100.0, 0.001, 260)
result = calculate_leadership(stock, bench)
# Legacy fields must remain present and numeric.
assert "stock_52w_performance" in result
assert "sp500_52w_performance" in result
assert "relative_performance" in result
assert "rs_rank_estimate" in result
assert isinstance(result["stock_52w_performance"], float)
assert isinstance(result["sp500_52w_performance"], float)
def test_new_fields_present(self):
stock = _make_price_series(100.0, 0.002, 260)
bench = _make_price_series(100.0, 0.001, 260)
result = calculate_leadership(stock, bench, rs_benchmark="^GSPC")
new_fields = [
"rs_3m_return",
"rs_6m_return",
"rs_12m_return",
"benchmark_3m_return",
"benchmark_6m_return",
"benchmark_12m_return",
"rel_3m",
"rel_6m",
"rel_12m",
"weighted_stock_performance",
"weighted_relative_performance",
"benchmark_52w_performance",
"rs_benchmark",
"rs_benchmark_relative_return",
"rs_rating",
"rs_component_score",
"rs_rank_percentile",
]
for field in new_fields:
assert field in result, f"Expected new field '{field}' in result"
def test_rs_benchmark_default(self):
stock = _make_price_series(100.0, 0.002, 260)
bench = _make_price_series(100.0, 0.001, 260)
result = calculate_leadership(stock, bench)
assert result["rs_benchmark"] == "^GSPC"
def test_rs_benchmark_custom(self):
stock = _make_price_series(100.0, 0.002, 260)
bench = _make_price_series(100.0, 0.001, 260)
result = calculate_leadership(stock, bench, rs_benchmark="SPY")
assert result["rs_benchmark"] == "SPY"
def test_no_benchmark_applies_penalty(self):
stock = _make_price_series(100.0, 0.002, 260)
bench = _make_price_series(100.0, 0.001, 260)
with_bench = calculate_leadership(stock, bench)
no_bench = calculate_leadership(stock, None)
# Without a benchmark, the score is reduced (20% penalty in score_leadership).
assert no_bench["score"] <= with_bench["score"]
assert no_bench["quality_warning"] is not None
def test_legacy_sp500_performance_path(self):
"""
When sp500_performance is supplied (pre-calculated benchmark return), the
function must take the legacy single-period path and leave multi-period
fields as None.
"""
stock = _make_price_series(100.0, 0.002, 260)
result = calculate_leadership(stock, sp500_performance=10.0)
assert result["score"] > 0
assert result["relative_performance"] is not None
assert result["rs_3m_return"] is None
assert result["rs_6m_return"] is None
assert result["rs_12m_return"] is None
assert result["weighted_relative_performance"] is None
def test_outperformer_scores_high(self):
# Stock +0.3%/day, benchmark +0.05%/day over a year => significant outperformance.
stock = _make_price_series(100.0, 0.003, 260)
bench = _make_price_series(100.0, 0.0005, 260)
result = calculate_leadership(stock, bench)
assert result["score"] >= 90
assert result["rs_rating"] in ("Market Leader", "Strong")
def test_60_bars_with_benchmark_uses_legacy_relative(self):
"""
Regression: when stock and benchmark both have 50-62 bars (multi-period 3m
window of 63 bars cannot be filled), scoring must fall back to the legacy
365-day relative performance with has_benchmark=True (not the absolute +
20% penalty path).
"""
# 60 bars: shorter than the 63-bar 3m window, so multi-period is fully missing.
# Stock returns ~+12.6%, benchmark returns ~+5.97% over the window.
stock = _make_price_series(100.0, 0.002, 60)
bench = _make_price_series(100.0, 0.001, 60)
result = calculate_leadership(stock, bench)
# Multi-period must indeed be empty for this regression to be meaningful.
assert result["weighted_relative_performance"] is None
assert result["weighted_stock_performance"] is None
# Legacy relative must be calculated (benchmark and stock both have >=50 bars).
assert result["relative_performance"] is not None
assert result["relative_performance"] > 0 # stock outperforms
# Compare against the no-benchmark path: with the proper fallback the
# benchmark-aware score must NOT be reduced by the 20% penalty.
no_bench = calculate_leadership(stock, None)
assert result["score"] >= no_bench["score"], (
"60-bar stock+benchmark should use legacy relative scoring, "
"not fall through to the absolute + 20% penalty path"
)
# ---------------------------------------------------------------------------
# screen_canslim CLI flags
# ---------------------------------------------------------------------------
class TestScreenCanslimCLIFlags:
"""
Verify that the CLI flag parser exposes --rs-benchmark and --disable-rs with
the expected defaults. We import lazily to avoid loading the FMP client at
module import time.
"""
def test_rs_benchmark_default(self):
import sys
from screen_canslim import parse_arguments
argv_backup = sys.argv
sys.argv = ["screen_canslim.py"]
try:
args = parse_arguments()
finally:
sys.argv = argv_backup
assert args.rs_benchmark == "^GSPC"
assert args.disable_rs is False
def test_rs_benchmark_custom(self):
import sys
from screen_canslim import parse_arguments
argv_backup = sys.argv
sys.argv = ["screen_canslim.py", "--rs-benchmark", "SPY"]
try:
args = parse_arguments()
finally:
sys.argv = argv_backup
assert args.rs_benchmark == "SPY"
def test_disable_rs_flag(self):
import sys
from screen_canslim import parse_arguments
argv_backup = sys.argv
sys.argv = ["screen_canslim.py", "--disable-rs"]
try:
args = parse_arguments()
finally:
sys.argv = argv_backup
assert args.disable_rs is True
class TestAnalyzeStockDisableRs:
"""
When disable_rs=True, analyze_stock should NOT call client.get_historical_prices
for the per-stock 365-day fetch and L should be a neutral 50 placeholder.
"""
def test_disable_rs_skips_365d_fetch(self, monkeypatch):
from finviz_stock_client import FinvizStockClient
from screen_canslim import analyze_stock
fallback = MagicMock(return_value=None)
monkeypatch.setattr(FinvizStockClient, "get_institutional_ownership", fallback)
client = MagicMock()
client.get_profile.return_value = [
{"companyName": "Test Corp", "sector": "Technology", "mktCap": 1_000_000_000}
]
client.get_quote.return_value = [{"price": 100.0, "yearHigh": 110.0, "yearLow": 70.0}]
client.get_income_statement.return_value = [
{"eps": 1.0, "revenue": 100_000_000, "date": "2024-12-31"}
] * 8
# Per-stock 90-day fetch for S component (still called)
ninety_day_series = [
{
"date": f"2024-{(i // 30) + 1:02d}-{(i % 30) + 1:02d}",
"close": 100 + i,
"volume": 1_000_000,
}
for i in range(90)
]
client.get_historical_prices.return_value = {"historical": ninety_day_series}
client.get_institutional_holders.return_value = [
{"holder": f"Inst {i}", "shares": 1000, "dateReported": "2025-01-01"} for i in range(50)
]
market_data = {
"score": 80,
"trend": "uptrend",
"sp500_price": 5000.0,
"sp500_ema_50": 4900.0,
"distance_from_ema_pct": 2.0,
}
result = analyze_stock(
"TEST",
client,
market_data,
rs_benchmark_historical=None,
rs_benchmark="^GSPC",
disable_rs=True,
)
assert result is not None
assert result["l_component"]["skipped"] is True
assert result["l_component"]["score"] == 50
assert result["l_component"]["rs_rating"] == "Skipped"
fallback.assert_called_once_with("TEST")
# Verify the only 365-day call (if any) did not happen by inspecting
# how many distinct days values were requested.
days_requested = {
call.kwargs.get("days", call.args[1] if len(call.args) > 1 else None)
for call in client.get_historical_prices.call_args_list
}
assert 365 not in days_requested, (
f"--disable-rs should not request 365-day history, got days={days_requested}"
)
# ---------------------------------------------------------------------------
# report_generator (summary table + L row)
# ---------------------------------------------------------------------------
class TestReportGeneratorRSColumn:
@staticmethod
def _make_stock_with_rs(rank: int) -> dict:
return {
"symbol": f"SYM{rank:02d}",
"company_name": f"Company {rank}",
"price": 100.0 + rank,
"market_cap": 1_000_000_000 * rank,
"sector": "Technology",
"composite_score": 90.0 - rank * 0.5,
"rating": "Strong",
"rating_description": "Solid across all components",
"guidance": "Buy",
"weakest_component": "M",
"weakest_score": 70,
"c_component": {"score": 80},
"a_component": {"score": 75},
"n_component": {"score": 70},
"s_component": {"score": 65},
"l_component": {
"score": 80,
"rs_3m_return": 12.4,
"rs_6m_return": 18.7,
"rs_12m_return": 44.1,
"rel_3m": 5.2,
"rel_6m": 8.3,
"rel_12m": 22.0,
"rs_rating": "Strong",
"rs_rank_percentile": 82,
"rs_benchmark": "^GSPC",
},
"i_component": {"score": 55},
"m_component": {"score": 70, "trend": "uptrend"},
}
def test_summary_table_appears(self):
results = [self._make_stock_with_rs(i) for i in range(1, 4)]
metadata = {
"generated_at": "2025-01-01 00:00:00 UTC",
"schema_version": "3.1",
"phase": "3.1 (7 components - FULL CANSLIM with multi-period RS)",
"components_included": ["C", "A", "N", "S", "L", "I", "M"],
"candidates_analyzed": 3,
"screening_options": {"rs_benchmark": "^GSPC", "rs_disabled": False},
"market_condition": {"trend": "uptrend", "M_score": 80, "warning": None},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
output_file = f.name
try:
generate_markdown_report(results, metadata, output_file)
with open(output_file, encoding="utf-8") as f:
content = f.read()
assert "## Summary Table" in content
assert "| RS Rating |" in content
assert "Strong" in content # rs_rating from fixture
assert "Schema Version" in content
# Multi-period RS row
assert "3m/6m/12m" in content
finally:
os.unlink(output_file)
def test_disable_rs_banner(self):
results = [self._make_stock_with_rs(1)]
# Override l_component to simulate a skipped run
results[0]["l_component"] = {"score": 50, "skipped": True, "rs_rating": "Skipped"}
metadata = {
"generated_at": "2025-01-01 00:00:00 UTC",
"schema_version": "3.1",
"phase": "3.1 (7 components - FULL CANSLIM with multi-period RS)",
"components_included": ["C", "A", "N", "S", "L", "I", "M"],
"candidates_analyzed": 1,
"screening_options": {"rs_benchmark": "^GSPC", "rs_disabled": True},
"market_condition": {"trend": "uptrend", "M_score": 80, "warning": None},
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
output_file = f.name
try:
generate_markdown_report(results, metadata, output_file)
with open(output_file, encoding="utf-8") as f:
content = f.read()
assert "RS Disabled" in content
assert "Skipped via --disable-rs" in content
finally:
os.unlink(output_file)
# ---------------------------------------------------------------------------
# Sanity check that DEFAULT_PERIODS_DAYS is consistent with the spec.
# ---------------------------------------------------------------------------
def test_default_periods_days():
assert DEFAULT_PERIODS_DAYS == {"3m": 63, "6m": 126, "12m": 252}
SKILL.md
---
name: canslim-screener
description: Screen US stocks using William O'Neil's CANSLIM growth stock methodology. Use when user requests CANSLIM stock screening, growth stock analysis, momentum stock identification, or wants to find stocks with strong earnings and price momentum following O'Neil's investment system.
---
# CANSLIM Stock Screener - Phase 3 (Full CANSLIM)
## Overview
This skill screens US stocks using William O'Neil's proven CANSLIM methodology, a systematic approach for identifying growth stocks with strong fundamentals and price momentum. CANSLIM analyzes 7 key components: **C**urrent Earnings, **A**nnual Growth, **N**ewness/New Highs, **S**upply/Demand, **L**eadership/RS Rank, **I**nstitutional Sponsorship, and **M**arket Direction.
**Phase 3** implements all 7 of 7 components (C, A, N, S, L, I, M), representing **100% of the full methodology**.
**Two-Stage Approach:**
1. **Stage 1 (FMP API + Finviz)**: Analyze stock universe with all 7 CANSLIM components
2. **Stage 2 (Reporting)**: Rank by composite score and generate actionable reports
**Key Features:**
- Composite scoring (0-100 scale) with weighted components
- **Finviz fallback** for institutional ownership data (automatic when FMP data incomplete)
- Progressive filtering to optimize API usage
- JSON + Markdown output formats
- Interpretation bands: Exceptional+ (90+), Exceptional (80-89), Strong (70-79), Above Average (60-69)
- Bear market protection (M component gating)
**Phase 3.1 Component Weights (Original O'Neil weights):**
- C (Current Earnings): 15%
- A (Annual Growth): 20%
- N (Newness): 15%
- S (Supply/Demand): 15%
- L (Leadership/RS Rank): 20% — multi-period weighted RS (3m/6m/12m vs configurable benchmark)
- I (Institutional): 10%
- M (Market Direction): 5%
**Weighted RS Formula:**
```
Weighted RS = 0.40 × rel_3m + 0.30 × rel_6m + 0.30 × rel_12m
```
Available periods are re-normalized when some are missing. Default benchmark is `^GSPC`;
override with `--rs-benchmark SPY/QQQ/IWM/...`.
**Fallback hierarchy when multi-period data is incomplete:**
1. No benchmark → weighted absolute stock performance + 20% penalty.
2. All multi-period windows missing but >=50 bars of price history → fall back to the
legacy 365-day full-window absolute return as the scoring input (20% penalty if no
benchmark).
3. <50 bars of price history → score=0 with `error` set.
**Future Phases:**
- Phase 4: FINVIZ Elite integration → 10x faster execution
---
## When to Use This Skill
**Explicit Triggers:**
- "Find CANSLIM stocks"
- "Screen for growth stocks using O'Neil's method"
- "Which stocks have strong earnings and momentum?"
- "Identify stocks near 52-week highs with accelerating earnings"
- "Run a CANSLIM screener on [sector/universe]"
**Implicit Triggers:**
- User wants to identify multi-bagger candidates
- User is looking for growth stocks with proven fundamentals
- User wants systematic stock selection based on historical winners
- User needs a ranked list of stocks meeting O'Neil's criteria
**When NOT to Use:**
- Value investing focus (use value-dividend-screener instead)
- Income/dividend focus (use dividend-growth-pullback-screener instead)
- Bear market conditions (M component will flag - consider raising cash)
---
## Prerequisites
**API Requirements:**
- **FMP API key** (free tier: 250 calls/day, sufficient for 35 stocks; Starter tier $29.99/mo for 40+ stocks)
- Sign up: https://site.financialmodelingprep.com/developer/docs
- Set via environment variable: `export FMP_API_KEY=your_key_here`
**Python Dependencies:**
- Python 3.9+
- `requests` (FMP API calls)
- `beautifulsoup4` (Finviz web scraping)
- `lxml` (HTML parsing)
**Installation:**
```bash
pip install requests beautifulsoup4 lxml
```
---
## Output
**Output Directory:** `reports/` (default) or custom via `--output-dir`
**Generated Files:**
- `canslim_screener_YYYY-MM-DD_HHMMSS.json` - Structured data for programmatic use
- `canslim_screener_YYYY-MM-DD_HHMMSS.md` - Human-readable report
**Report Contents:**
- Market Condition Summary (trend, M score, warnings)
- Top N CANSLIM Candidates (ranked by composite score)
- Component Breakdown for each stock (C, A, N, S, L, I, M scores with details)
- Rating interpretation (Exceptional+/Exceptional/Strong/Above Average)
- Quality warnings and data source notes
- Summary statistics (rating distribution)
**Rating Bands:**
- **Exceptional+ (90-100):** All components near-perfect, aggressive buy
- **Exceptional (80-89):** Outstanding fundamentals + momentum, strong buy
- **Strong (70-79):** Solid across components, standard buy
- **Above Average (60-69):** Meets thresholds with minor weaknesses, buy on pullback
---
## Workflow
### Step 1: Verify API Access and Requirements
Check if user has FMP API key configured:
```bash
# Check environment variable
echo $FMP_API_KEY
# If not set, prompt user to provide it
```
**Requirements:**
- **FMP API key** (free tier: 250 calls/day, sufficient for 40 stocks)
- **Python 3.9+** with required libraries:
- `requests` (FMP API calls)
- `beautifulsoup4` (Finviz web scraping)
- `lxml` (HTML parsing)
**Installation:**
```bash
pip install requests beautifulsoup4 lxml
```
If API key is missing, guide user to:
1. Sign up at https://site.financialmodelingprep.com/developer/docs
2. Get free API key (250 calls/day)
3. Set environment variable: `export FMP_API_KEY=your_key_here`
### Step 2: Determine Stock Universe
**Option A: Default Universe (Recommended)**
Use top 40 S&P 500 stocks by market cap (predefined in script):
```bash
python3 skills/canslim-screener/scripts/screen_canslim.py
```
**Option B: Custom Universe**
User provides specific symbols or sector:
```bash
python3 skills/canslim-screener/scripts/screen_canslim.py \
--universe AAPL MSFT GOOGL AMZN NVDA META TSLA
```
**Option C: Sector-Specific**
User can provide sector-focused list (Technology, Healthcare, etc.)
**API Budget Considerations (Phase 3):**
- 40 stocks × 7 FMP calls/stock = 280 API calls
- FMP: 7 calls/stock (profile, quote, income×2, historical_90d, historical_365d, institutional)
- Finviz: ~1.8 calls/stock (institutional ownership fallback, 2s rate limit, not counted in FMP budget)
- Market data (^GSPC quote, ^VIX quote, ^GSPC 52-week history): 3 FMP calls
- Total: ~283 FMP calls per screening run (exceeds 250 free tier)
- **Recommendation**: Use `--max-candidates 35` for free tier (35 × 7 + 3 = 248 calls), or upgrade to FMP Starter tier ($29.99/mo, 750 calls/day) for full 40-stock screening
### Step 3: Execute CANSLIM Screening Script
Run the main screening script with appropriate parameters:
```bash
cd skills/canslim-screener/scripts
# Basic run (40 stocks, top 20 in report)
python3 screen_canslim.py --api-key $FMP_API_KEY
# Custom parameters
python3 screen_canslim.py \
--api-key $FMP_API_KEY \
--max-candidates 40 \
--top 20 \
--output-dir ../../../
# Custom RS benchmark (Phase 3.1)
python3 screen_canslim.py --rs-benchmark SPY
# Disable L component (saves per-stock 365-day fetch; L fixed at neutral 50)
python3 screen_canslim.py --disable-rs
```
**Script Workflow (Phase 3 - Full CANSLIM):**
1. **Market Direction (M)**: Analyze S&P 500 trend vs 50-day EMA (using real historical data for accurate EMA)
- If bear market detected (M=0), warn user to raise cash
2. **S&P 500 Historical Data**: Fetch 52-week data for M component EMA and L component RS calculation
3. **Stock Analysis**: For each stock, calculate:
- **C Component**: Quarterly EPS/revenue growth (YoY)
- **A Component**: 3-year EPS CAGR and stability
- **N Component**: Distance from 52-week high, breakout detection
- **S Component**: Volume-based accumulation/distribution (up-day vs down-day volume)
- **L Component**: 52-week Relative Strength vs S&P 500
- **I Component**: Institutional holder count + ownership % (with Finviz fallback)
4. **Composite Scoring**: Weighted average with all 7 component breakdown
5. **Ranking**: Sort by composite score (highest first)
6. **Reporting**: Generate JSON + Markdown outputs
**Expected Execution Time (Phase 3):**
- 40 stocks: **~2 minutes** (additional 52-week history fetch per stock for L component)
- Finviz fallback adds ~2 seconds per stock (rate limiting)
- L component requires 365-day historical data for each stock
**Finviz Fallback Behavior:**
- Triggers automatically when FMP `sharesOutstanding` unavailable
- Scrapes institutional ownership % from Finviz.com (free, no API key)
- Increases I component accuracy from 35/100 (partial data) to 60-100/100 (full data)
- User sees: `✅ Using Finviz institutional ownership for NVDA: 68.3%`
### Step 4: Read and Parse Screening Results
The script generates two output files:
- `canslim_screener_YYYY-MM-DD_HHMMSS.json` - Structured data
- `canslim_screener_YYYY-MM-DD_HHMMSS.md` - Human-readable report
Read the Markdown report to identify top candidates:
```bash
# Find the latest report
ls -lt canslim_screener_*.md | head -1
# Read the report
cat canslim_screener_YYYY-MM-DD_HHMMSS.md
```
**Report Structure (Phase 3 - Full CANSLIM):**
- Market Condition Summary (trend, M score, warnings)
- Top N CANSLIM Candidates (ranked, N = --top parameter)
- For each stock:
- Composite Score and Rating (Exceptional+/Exceptional/Strong/etc.)
- Component Breakdown (C, A, N, S, L, I, M scores with details)
- Interpretation (rating description, guidance, weakest component)
- Warnings (quality issues, market conditions, data source notes)
- Summary Statistics (rating distribution)
- Methodology note (Phase 3: 7 components, 100% coverage)
**Component Details in Report:**
- **S Component**: "Up/Down Volume Ratio: 1.06 ✓ Accumulation"
- **L Component (Phase 3.1)**: "3m/6m/12m: +12.4%/+18.7%/+44.1% (rel +5.2%/+8.3%/+22.0%) | RS: 88 (Strong)"
- **I Component**: "6199 holders, 68.3% ownership ⭐ Superinvestor"
A new **Summary Table** appears above the candidate list in Phase 3.1 reports, showing
rank, symbol, composite score, rating, RS rating, and RS percentile for quick scanning.
### Step 5: Analyze Top Candidates and Provide Recommendations
Review the top-ranked stocks and cross-reference with knowledge bases:
**Reference Documents to Consult:**
1. `references/interpretation_guide.md` - Understand rating bands and portfolio sizing
2. `references/canslim_methodology.md` - Deep dive into component meanings (now includes S and I)
3. `references/scoring_system.md` - Understand scoring formulas (Phase 3 weights)
**Analysis Framework:**
For **Exceptional+ stocks (90-100 points)**:
- All components near-perfect (C≥85, A≥85, N≥85, S≥80, L≥85, I≥80, M≥80)
- Guidance: Immediate buy, aggressive position sizing (15-20% of portfolio)
- Example: "NVDA scores 97.2 - explosive quarterly earnings (100), strong 3-year growth (95), at new highs (98), volume accumulation (85), RS leader (92), strong institutional support (90), uptrend market (100)"
For **Exceptional stocks (80-89 points)**:
- Outstanding fundamentals + strong momentum
- Guidance: Strong buy, standard sizing (10-15% of portfolio)
For **Strong stocks (70-79 points)**:
- Solid across all components, minor weaknesses
- Guidance: Buy, standard sizing (8-12% of portfolio)
- Phase 3 Example: "Stock scores 77.5 - strong earnings (85), solid growth (80), near high (70), accumulation (60), RS leader (75), good institutions (60), uptrend (90)"
For **Above Average stocks (60-69 points)**:
- Meets thresholds, one component weak
- Guidance: Buy on pullback, conservative sizing (5-8% of portfolio)
**Bear Market Override:**
- If M component = 0 (bear market detected), **do NOT buy** regardless of other scores
- Guidance: Raise 80-100% cash, wait for market recovery
- CANSLIM does not work in bear markets (3 out of 4 stocks follow market trend)
### Step 6: Generate User-Facing Report
Create a concise, actionable summary for the user:
**Report Format:**
```markdown
# CANSLIM Stock Screening Results (Phase 3 - Full CANSLIM)
**Date:** YYYY-MM-DD
**Market Condition:** [Trend] - M Score: [X]/100
**Stocks Analyzed:** [N]
**Components:** C, A, N, S, L, I, M (7 of 7, 100% coverage)
## Market Summary
[2-3 sentences on current market environment based on M component]
[If bear market: WARNING - Consider raising cash allocation]
## Top 5 CANSLIM Candidates
### 1. [SYMBOL] - [Company Name] ⭐⭐⭐
**Score:** [X.X]/100 ([Rating])
**Price:** $[XXX.XX] | **Sector:** [Sector]
**Component Breakdown:**
- C (Earnings): [X]/100 - [EPS growth]% QoQ, [Revenue growth]% revenue
- A (Growth): [X]/100 - [CAGR]% 3yr EPS CAGR
- N (Newness): [X]/100 - [Distance]% from 52wk high
- S (Supply/Demand): [X]/100 - Up/Down Volume Ratio: [X.XX]
- L (Leadership): [X]/100 - 52wk: [+X.X]% ([+X.X]% vs S&P) RS: [XX]
- I (Institutional): [X]/100 - [N] holders, [X.X]% ownership [⭐ Superinvestor if present]
- M (Market): [X]/100 - [Trend]
**Interpretation:** [Rating description and guidance]
**Weakest Component:** [X] ([score])
**Data Source Note:** [If Finviz used: "Institutional data from Finviz"]
[Repeat for top 5 stocks]
## Investment Recommendations
**Immediate Buy List (90+ score):**
- [List stocks with exceptional+ ratings]
- Position sizing: 15-20% each
**Strong Buy List (80-89 score):**
- [List stocks with exceptional ratings]
- Position sizing: 10-15% each
**Watchlist (70-79 score):**
- [List stocks with strong ratings]
- Buy on pullback
## Risk Factors
- [Identify any quality warnings from components]
- [Market condition warnings]
- [Sector concentration risks if applicable]
- [Data source reliability notes if Finviz heavily used]
## Next Steps
1. Conduct detailed fundamental analysis on top 3 candidates
2. Check earnings calendars for upcoming reports
3. Review technical charts for entry timing
4. [If bear market: Wait for market recovery before deploying capital]
---
**Note:** This is Phase 3 (Full CANSLIM: C, A, N, S, L, I, M - 100% coverage).
```
---
## Resources
### Scripts Directory (`scripts/`)
**Main Scripts:**
- `screen_canslim.py` - Main orchestrator script
- Entry point for screening workflow
- Handles argument parsing, API coordination, ranking, reporting
- Usage: `python3 screen_canslim.py --api-key KEY [options]`
- `fmp_client.py` - FMP API client wrapper
- Rate limiting (0.3s between calls)
- 429 error handling with 60s retry
- Session-based caching
- Methods: `get_income_statement()`, `get_quote()`, `get_historical_prices()`, `get_institutional_holders()`
- `finviz_stock_client.py` - Finviz web scraping client ← **NEW**
- BeautifulSoup-based HTML parsing
- Fetches institutional ownership % from Finviz.com
- Rate limiting (2.0s between calls)
- No API key required (free web scraping)
- Methods: `get_institutional_ownership()`, `get_stock_data()`
**Calculators (`scripts/calculators/`):**
- `earnings_calculator.py` - C component (Current Earnings)
- Quarterly EPS/revenue growth (YoY)
- Scoring: 50%+ = 100pts, 30-49% = 80pts, 18-29% = 60pts
- `growth_calculator.py` - A component (Annual Growth)
- 3-year EPS CAGR calculation
- Stability check (no negative growth years)
- Scoring: 40%+ = 90pts, 30-39% = 70pts, 25-29% = 50pts
- `new_highs_calculator.py` - N component (Newness)
- Distance from 52-week high
- Volume-confirmed breakout detection
- Scoring: 5% of high + breakout = 100pts, 10% + breakout = 80pts
- `supply_demand_calculator.py` - S component (Supply/Demand) ← **NEW**
- Volume-based accumulation/distribution analysis
- Up-day volume vs down-day volume ratio (60-day lookback)
- Scoring: ratio ≥2.0 = 100pts, 1.5-2.0 = 80pts, 1.0-1.5 = 60pts
- `leadership_calculator.py` - L component (Leadership/Relative Strength)
- 52-week stock performance vs S&P 500 benchmark
- RS Rank estimation (1-99 scale, O'Neil style)
- Scoring: RS 90+ outperforming market = 100pts, RS 80-89 = 80pts
- `institutional_calculator.py` - I component (Institutional)
- Institutional holder count (from FMP)
- Ownership % (from FMP or Finviz fallback)
- Superinvestor detection (Berkshire Hathaway, Baupost, etc.)
- Scoring: 50-100 holders + 30-60% ownership = 100pts
- `market_calculator.py` - M component (Market Direction)
- S&P 500 vs 50-day EMA
- VIX-adjusted scoring
- Scoring: Strong uptrend = 100pts, Uptrend = 80pts, Bear market = 0pts
**Supporting Modules:**
- `scorer.py` - Composite score calculation
- Phase 3 weighted average: C×15% + A×20% + N×15% + S×15% + L×20% + I×10% + M×5%
- Rating interpretation (Exceptional+/Exceptional/Strong/etc.)
- Minimum threshold validation (all 7 components must meet baseline)
- `report_generator.py` - Output generation
- JSON export (programmatic use)
- Markdown export (human-readable)
- Phase 3 component breakdown tables (all 7 components)
- Summary statistics calculation
### References Directory (`references/`)
**Knowledge Bases:**
- `references/canslim_methodology.md` (27KB) - Complete CANSLIM explanation
- All 7 components with O'Neil's original thresholds
- S component (Volume accumulation/distribution) detailed explanation
- L component (Leadership/Relative Strength) detailed explanation
- I component (Institutional sponsorship) detailed explanation
- Historical examples (AAPL 2009, NFLX 2013, TSLA 2019, NVDA 2023)
- `references/scoring_system.md` (21KB) - Technical scoring specification (Phase 3)
- Phase 3 component weights and formulas (all 7 components)
- Interpretation bands (90-100, 80-89, etc.)
- Minimum thresholds for all 7 components
- Composite score calculation examples
- `references/fmp_api_endpoints.md` (18KB) - API integration guide (Phase 3)
- Required endpoints for all 7 components
- L component: 52-week historical prices endpoint
- Institutional holder endpoint documentation
- Finviz fallback strategy explanation
- Rate limiting strategy
- Cost analysis (Phase 3: ~283 FMP calls for 40 stocks, exceeds 250 free tier)
- `references/interpretation_guide.md` (18KB) - User guidance
- Portfolio construction rules
- Position sizing by rating
- Entry/exit strategies
- Bear market protection rules
**How to Use References:**
- Read `references/canslim_methodology.md` first to understand O'Neil's system (now includes S and I)
- Consult `references/interpretation_guide.md` when analyzing results
- Reference `references/scoring_system.md` if scores seem unexpected
- Check `references/fmp_api_endpoints.md` for API troubleshooting or Finviz fallback issues
---
## Troubleshooting
### Issue 1: FMP API Rate Limit Exceeded
**Symptoms:**
```
ERROR: 429 Too Many Requests - Rate limit exceeded
Retrying in 60 seconds...
```
**Causes:**
- Running multiple screenings within short time window
- Exceeding 250 calls/day (free tier limit)
- Other applications using same API key
**Solutions:**
1. **Wait and Retry**: Script auto-retries after 60s
2. **Reduce Universe**: Use `--max-candidates 30` to lower API usage
3. **Check Daily Usage**: Free tier resets at midnight UTC
4. **Upgrade Plan**: FMP Starter ($29.99/month) provides 750 calls/day
### Issue 2: Missing Required Libraries
**Symptoms:**
```
ERROR: required libraries not found. Install with: pip install beautifulsoup4 requests lxml
```
**Solutions:**
```bash
# Install all required libraries
pip install requests beautifulsoup4 lxml
# Or install individually
pip install beautifulsoup4
pip install requests
pip install lxml
```
### Issue 3: Finviz Fallback Slow Execution
**Symptoms:**
```
Execution time: 2 minutes 30 seconds for 40 stocks (slower than expected)
```
**Causes:**
- Finviz rate limiting (2.0s per request)
- All stocks triggering fallback due to FMP data gaps
**Solutions:**
1. **Accept Delay**: 1-2 minutes for 40 stocks is normal with Finviz fallback
2. **Monitor Fallback Usage**: Check logs for "Using Finviz institutional ownership" messages
3. **Reduce Rate Limit** (advanced): Edit `finviz_stock_client.py`, change `rate_limit_seconds=2.0` to `1.5` (risk: IP ban)
**Note:** Finviz fallback adds ~2 seconds per stock but significantly improves I component accuracy (35 → 60-100 points).
### Issue 4: Finviz Web Scraping Failure
**Symptoms:**
```
WARNING: Finviz request failed with status 403 for NVDA
⚠️ Using Finviz institutional ownership data - FMP shares outstanding unavailable. Finviz fallback also unavailable. Score reduced by 50%.
```
**Causes:**
- Finviz blocking scraping requests (User-Agent detection)
- Rate limit exceeded (too many requests)
- Network issues or Finviz downtime
**Solutions:**
1. **Wait and Retry**: Rate limit resets after a few minutes
2. **Check Internet Connection**: Verify network access to finviz.com
3. **Fallback Accepted**: Script continues with FMP holder count only (I score capped at 70/100)
4. **Manual Verification**: Check Finviz website manually for blocked IP
**Graceful Degradation:**
- Script never fails due to Finviz issues
- Falls back to FMP holder count only
- User sees quality warning in report
### Issue 5: No Stocks Meet Minimum Thresholds
**Symptoms:**
```
✓ Successfully analyzed 40 stocks
Top 5 Stocks:
1. AAPL - 58.3 (Average)
2. MSFT - 55.1 (Average)
...
```
**Causes:**
- Bear market conditions (M component low)
- Selected universe lacks growth stocks
- Market rotation away from growth
**Solutions:**
1. **Check M Component**: If M=0 (bear market), raise cash per CANSLIM rules
2. **Expand Universe**: Try different sectors or market cap ranges
3. **Lower Expectations**: Average scores (55-65) may still be actionable in weak markets
4. **Wait for Better Setup**: CANSLIM works best in bull markets
### Issue 6: Data Quality Warnings
**Symptoms:**
```
⚠️ Revenue declining despite EPS growth (possible buyback distortion)
⚠️ Using Finviz institutional ownership data (68.3%) - FMP shares outstanding unavailable.
```
**Interpretation:**
- These are **not errors** - they are quality flags from calculators
- Revenue warning: EPS growth may be from share buybacks, not organic growth
- Finviz warning: Data source switched from FMP to Finviz (still accurate)
**Actions:**
1. Review component details in full report
2. Cross-check with fundamental analysis
3. Adjust position sizing based on risk level
4. Finviz data is reliable - no action needed for data source warnings
---
## Important Notes
### Phase 3 Implementation Status
This is **Phase 3** implementing all 7 of 7 CANSLIM components:
- ✅ **C** (Current Earnings) - Implemented
- ✅ **A** (Annual Growth) - Implemented
- ✅ **N** (Newness) - Implemented
- ✅ **S** (Supply/Demand) - Implemented
- ✅ **L** (Leadership/RS Rank) - Implemented
- ✅ **I** (Institutional) - Implemented
- ✅ **M** (Market Direction) - Implemented
**Implications:**
- Composite scores represent **100% of full CANSLIM methodology**
- Uses original O'Neil component weights (C 15%, A 20%, N 15%, S 15%, L 20%, I 10%, M 5%)
- L component (20% weight) is the largest individual factor alongside A, emphasizing relative strength leadership
- M component uses real 50-day EMA from historical data (not fallback estimate)
### Finviz Integration Benefits
**Automatic Fallback System:**
- When FMP API doesn't provide `sharesOutstanding`, Finviz automatically activates
- Scrapes institutional ownership % from Finviz.com (free, no API key)
- Improves I component accuracy from 35/100 (partial) to 60-100/100 (full)
**Data Source Priority:**
1. **FMP API** (primary): Institutional holder count + shares outstanding calculation
2. **Finviz** (fallback): Direct institutional ownership % from web page
3. **Partial Data** (last resort): Holder count only, 50% penalty applied
**Tested Reliability:**
- 39/39 stocks successfully retrieved ownership % via Finviz (100% success rate)
- Average execution time: 2.54 seconds per stock
- No errors or IP blocks during testing
### Future Enhancements
**Phase 4 (Planned):**
- FINVIZ Elite integration for pre-screening
- Execution time: 2 minutes → 10-15 seconds
- FMP API usage reduction: 90%
- Larger universe possible (100+ stocks)
### Data Source Attribution
- **FMP API**: Income statements, quotes, historical prices, key metrics, institutional holders
- **Finviz**: Institutional ownership % (fallback), market data
- **Methodology**: William O'Neil's "How to Make Money in Stocks" (4th edition)
- **Scoring System**: Adapted from IBD MarketSmith proprietary system
### Disclaimer
**This screener is for educational and informational purposes only.**
- Not investment advice
- Past performance does not guarantee future results
- CANSLIM methodology works best in bull markets (M component confirms)
- Conduct your own research and consult a financial advisor before making investment decisions
- O'Neil's historical winners include AAPL (2009: +1,200%), NFLX (2013: +800%), but many stocks fail to perform
---
**Version:** Phase 3.1 (multi-period RS)
**Last Updated:** 2026-05-03
**API Requirements:** FMP API (free tier: up to 35 stocks; Starter tier recommended for 40 stocks) + BeautifulSoup/requests/lxml for Finviz
**Execution Time:** ~2 minutes for 40 stocks
**Output Formats:** JSON + Markdown (now includes Summary Table and `schema_version: "3.1"`)
**Components Implemented:** C, A, N, S, L, I, M (7 of 7, 100% coverage)
**Phase 3.1 additions:** multi-period RS (3m/6m/12m), `--rs-benchmark`, `--disable-rs`,
new RS fields (`rs_rating`, `rs_rank_percentile`, `rs_3m_return`, `rs_6m_return`,
`rs_12m_return`, `rs_benchmark`, `rs_benchmark_relative_return`, `rs_component_score`,
`benchmark_52w_performance`).