
Market Top Detector
- 887 installs
- 2.5k repo stars
- Updated July 26, 2026
- tradermonty/claude-trading-skills
Market Top Detector is a Claude Code skill that applies William O'Neil distribution day and stalling day rules to S&P 500 and NASDAQ Composite data for developers and traders monitoring major index uptrend health.
About
Market Top Detector is an agent skill from tradermonty/claude-trading-skills encoding William O'Neil market-top detection from "How to Make Money in Stocks." A Distribution Day occurs when a major index (S&P 500 or NASDAQ Composite) declines by 0.2% or more on volume higher than the previous trading day, signaling institutional selling. Stalling day criteria complement distribution signals to judge uptrend pressure. Both price decline and volume conditions must be met for a valid distribution day reading. Developers and quantitative traders reach for Market Top Detector when building or running agent-assisted market regime checks that flag when a major index uptrend is under institutional distribution rather than relying on headline price moves alone.
- Defines distribution days: index down ≥0.2% on higher volume than the prior session
- Defines stalling days (higher volume, gain <0.1%) counted as 0.5 distribution days
- 25-trading-day expiration window for distribution signals
- Explicit exclusions: lower-volume declines and weak participation do not count
- Interpretation table tying effective distribution count to recommended action
Market Top Detector by the numbers
- 887 all-time installs (skills.sh)
- +47 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #167 of 1,136 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill market-top-detectorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 887 |
|---|---|
| repo stars | ★ 2.5k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 26, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you detect market tops using distribution days?
Apply William O’Neil-style distribution and stalling day rules to judge when a major index uptrend is under institutional selling pressure.
Who is it for?
Developers building trading analysis agents who apply William O'Neil distribution and stalling day rules to major US index data.
Skip if: Long-term passive investors or developers seeking fundamental valuation models unrelated to O'Neil volume-and-price distribution patterns.
When should I use this skill?
User asks to detect market tops, count distribution days, or apply William O'Neil stalling day rules on S&P 500 or NASDAQ data.
What you get
Distribution day and stalling day signals with index decline percentages and volume comparisons against prior sessions.
- distribution day signal report
- stalling day analysis
By the numbers
- Distribution Day requires index decline >= 0.2% with volume above the previous trading day
Files
Market Top Detector Skill
Purpose
Detect the probability of a market top formation using a quantitative 6-component scoring system (0-100). Integrates three proven market top detection methodologies:
1. O'Neil - Distribution Day accumulation (institutional selling) 2. Minervini - Leading stock deterioration pattern 3. Monty - Defensive sector rotation signal
Unlike the Bubble Detector (macro/multi-month evaluation), this skill focuses on tactical 2-8 week timing signals that precede 10-20% market corrections.
When to Use This Skill
English:
- User asks "Is the market topping?" or "Are we near a top?"
- User notices distribution days accumulating
- User observes defensive sectors outperforming growth
- User sees leading stocks breaking down while indices hold
- User asks about reducing equity exposure timing
- User wants to assess correction probability for the next 2-8 weeks
Japanese:
- 「天井が近い?」「今は利確すべき?」
- ディストリビューションデーの蓄積を懸念
- ディフェンシブセクターがグロースをアウトパフォーム
- 先導株が崩れ始めているが指数はまだ持ちこたえている
- エクスポージャー縮小のタイミング判断
- 今後2〜8週間の調整確率を評価したい
Prerequisites
Required:
- FMP API Key: Set
$FMP_API_KEYenvironment variable or pass--api-key. Free tier sufficient (~33 API calls per execution). - WebSearch Access: Required to collect S&P 500 breadth (50DMA %) and CBOE Put/Call ratio data.
Optional:
- Margin Debt Data: Enhances sentiment scoring but typically 1-2 months lagged.
- VIX Term Structure: Auto-detected from FMP API if VIX3M quote available; manual override via
--vix-term.
Data Freshness: All manually collected data should be from the most recent 3 business days for accurate analysis.
Difference from Bubble Detector
| Aspect | Market Top Detector | Bubble Detector |
|---|---|---|
| Timeframe | 2-8 weeks | Months to years |
| Target | 10-20% correction | Bubble collapse (30%+) |
| Methodology | O'Neil/Minervini/Monty | Minsky/Kindleberger |
| Data | Price/Volume + Breadth | Valuation + Sentiment + Social |
| Score Range | 0-100 composite | 0-15 points |
---
Execution Workflow
Phase 1: Data Collection via WebSearch
Before running the Python script, collect the following data using WebSearch. Data Freshness Requirement: All data must be from the most recent 3 business days. Stale data degrades analysis quality.
1. S&P 500 Breadth (200DMA above %)
AUTO-FETCHED from TraderMonty CSV (no WebSearch needed)
The script fetches this automatically from GitHub Pages CSV data.
Override: --breadth-200dma [VALUE] to use a manual value instead.
Disable: --no-auto-breadth to skip auto-fetch entirely.
2. [REQUIRED] S&P 500 Breadth (50DMA above %)
Valid range: 20-100
Primary search: "S&P 500 percent stocks above 50 day moving average"
Fallback: "market breadth 50dma site:barchart.com"
Direct fallback when search snippets are poor: fetch `https://www.barchart.com/stocks/quotes/$S5FI/overview` and extract the embedded `lastPrice` / `tradeTime` for “S&P 500 Stocks Above 50-Day Average”.
Record the data date
3. [REQUIRED] CBOE Equity Put/Call Ratio
Valid range: 0.30-1.50
Primary search: "CBOE equity put call ratio today"
Fallback: "CBOE total put call ratio current"
Fallback: "put call ratio site:cboe.com"
Direct fallback when Cboe CSV endpoints are stale: fetch `https://ycharts.com/indicators/cboe_equity_put_call_ratio` and parse the “Last Value” / “Latest Period” table fields. Treat this as a secondary source and cite it in freshness notes.
Record the data date
4. [OPTIONAL] VIX Term Structure
Values: steep_contango / contango / flat / backwardation
Primary search: "VIX VIX3M ratio term structure today"
Fallback: "VIX futures term structure contango backwardation"
Note: Auto-detected from FMP API if VIX3M quote available.
CLI --vix-term overrides auto-detection.
5. [OPTIONAL] Margin Debt YoY %
Primary search: "FINRA margin debt latest year over year percent"
Fallback: "NYSE margin debt monthly"
Note: Typically 1-2 months lagged. Record the reporting month.Phase 2: Execute Python Script
Run the script with collected data as CLI arguments:
python3 skills/market-top-detector/scripts/market_top_detector.py \
--api-key $FMP_API_KEY \
--breadth-50dma [VALUE] --breadth-50dma-date [YYYY-MM-DD] \
--put-call [VALUE] --put-call-date [YYYY-MM-DD] \
--vix-term [steep_contango|contango|flat|backwardation] \
--margin-debt-yoy [VALUE] --margin-debt-date [YYYY-MM-DD] \
--output-dir reports/ \
--context "Consumer Confidence=[VALUE]" "Gold Price=[VALUE]"
# 200DMA breadth is auto-fetched from TraderMonty CSV.
# Override with --breadth-200dma [VALUE] if needed.
# Disable with --no-auto-breadth to skip auto-fetch.The script will: 1. Fetch S&P 500, QQQ, VIX quotes and history from FMP API 2. Fetch Leading ETF (ARKK, WCLD, IGV, XBI, SOXX, SMH, KWEB, TAN) data 3. Fetch Sector ETF (XLU, XLP, XLV, VNQ, XLK, XLC, XLY) data 4. Calculate all 6 components 5. Generate composite score and reports
Phase 3: Present Results
Present the generated Markdown report to the user, highlighting:
- Composite score and risk zone
- Data freshness warnings (if any data older than 3 days)
- Strongest warning signal (highest component score)
- Historical comparison (closest past top pattern)
- What-if scenarios (sensitivity to key changes)
- Recommended actions based on risk zone
- Follow-Through Day status (if applicable)
- Delta vs previous run (if prior report exists)
---
6-Component Scoring System
| # | Component | Weight | Data Source | Key Signal |
|---|---|---|---|---|
| 1 | Distribution Day Count | 25% | FMP API | Institutional selling in last 25 trading days |
| 2 | Leading Stock Health | 20% | FMP API | Growth ETF basket deterioration |
| 3 | Defensive Sector Rotation | 15% | FMP API | Defensive vs Growth relative performance |
| 4 | Market Breadth Divergence | 15% | Auto (CSV) + WebSearch | 200DMA (auto) / 50DMA (WebSearch) breadth vs index level |
| 5 | Index Technical Condition | 15% | FMP API | MA structure, failed rallies, lower highs |
| 6 | Sentiment & Speculation | 10% | FMP + WebSearch | VIX, Put/Call, term structure |
Risk Zone Mapping
| Score | Zone | Risk Budget | Action |
|---|---|---|---|
| 0-20 | Green (Normal) | 100% | Normal operations |
| 21-40 | Yellow (Early Warning) | 80-90% | Tighten stops, reduce new entries |
| 41-60 | Orange (Elevated Risk) | 60-75% | Profit-taking on weak positions |
| 61-80 | Red (High Probability Top) | 40-55% | Aggressive profit-taking |
| 81-100 | Critical (Top Formation) | 20-35% | Maximum defense, hedging |
---
API Requirements
Required: FMP API key (free tier sufficient: ~33 calls per execution) Optional: WebSearch data for breadth and sentiment (improves accuracy)
Output Files
- JSON:
market_top_YYYY-MM-DD_HHMMSS.json - Markdown:
market_top_YYYY-MM-DD_HHMMSS.md
Reference Documents
references/market_top_methodology.md
- Full methodology with O'Neil, Minervini, and Monty frameworks
- Component scoring details and thresholds
- Historical validation notes
references/distribution_day_guide.md
- Detailed O'Neil Distribution Day rules
- Stalling day identification
- Follow-Through Day (FTD) mechanics
references/historical_tops.md
- Analysis of 2000, 2007, 2018, 2022 market tops
- Component score patterns during historical tops
- Lessons learned and calibration data
When to Load References
- First use: Load
market_top_methodology.mdfor full framework understanding - Distribution day questions: Load
distribution_day_guide.md - Historical context: Load
historical_tops.md - Regular execution: References not needed - script handles scoring
Distribution Day Guide
What is a Distribution Day?
A Distribution Day occurs when a major stock index (S&P 500 or NASDAQ Composite) declines by 0.2% or more on volume that is higher than the previous trading day. This pattern indicates institutional investors are selling stocks in volume - "distributing" their holdings.
William O'Neil introduced this concept in "How to Make Money in Stocks" as one of the most reliable signals that a market uptrend is under pressure.
Precise Rules
Distribution Day Criteria (BOTH must be met):
1. Price decline >= 0.2% from previous session's close 2. Volume > previous day's volume (even slightly higher counts)
Stalling Day Criteria (BOTH must be met):
1. Volume > previous day's volume (institutional activity present) 2. Price gain < 0.1% (price fails to advance meaningfully despite volume) 3. Stalling days count as 0.5 distribution days in the effective count
25-Day Expiration Rule:
- A distribution day automatically expires after 25 trading days (approximately 5 weeks)
- Only the rolling 25-day window matters
- This prevents old distribution from affecting current assessment
What Does NOT Count as Distribution:
- Index declines on lower volume (normal profit-taking)
- Large gap-up open followed by slight closing decline (still net positive day)
- Volume below average (lack of institutional participation)
Counting and Interpretation
| Effective Count | Interpretation | Action |
|---|---|---|
| 0-1 | Healthy market | Normal operations |
| 2-3 | Some selling but normal | Monitor closely |
| 4 | O'Neil's initial warning | Tighten stops |
| 5 | Significant distribution | Reduce exposure |
| 6+ | Heavy distribution | Protect capital aggressively |
Effective Count Formula:
Effective Count = Distribution Days + (0.5 × Stalling Days)Dual-Index Approach
This tool monitors both the S&P 500 and NASDAQ (via QQQ). The higher effective count between the two indices is used for scoring, because:
1. Distribution can concentrate in one index before the other 2. NASDAQ often leads due to higher growth stock concentration 3. The worst signal from either index is the most conservative approach
Common Patterns at Market Tops
Classic Distribution Sequence (2022 Example):
1. Week 1: One distribution day (seems harmless) 2. Week 2: Two more distribution days 3. Week 3: One stalling day (selling into rallies) 4. Week 4: Two more distribution days (total: 5-6 effective) 5. Market breaks down shortly after
False Alarm Pattern:
1. Two distribution days in quick succession 2. Market rallies strongly, erasing concerns 3. Distribution days expire without reaching critical mass 4. Uptrend continues
Key Distinction:
The clustering of distribution days matters. Three distribution days in one week is more alarming than three spread over five weeks.
Follow-Through Day (FTD)
The FTD is the counterpart to distribution days - it signals a potential new uptrend after a correction.
FTD Rules:
1. Rally Attempt: Market makes its low and begins to rally (first up day) 2. Counting: Start counting from day 1 of the rally attempt 3. FTD Occurs on Day 4-7: A significant price gain (>1.5%) with volume higher than the previous day 4. Confirmation: The FTD should be on day 4 at the earliest (premature rallies on days 1-3 are unreliable)
FTD Reliability:
- Not all FTDs lead to sustained uptrends (~25% fail)
- Multiple FTDs within a short period increase confidence
- FTDs work best when combined with improving breadth
- O'Neil: "I never try to buy at the bottom. I wait for the Follow-Through Day."
Practical Tips
When Monitoring Distribution:
1. Check distribution count DAILY during uncertain markets 2. Pay special attention when count reaches 3 (pre-warning) 3. At 4+, begin defensive actions regardless of other signals 4. Don't wait for 6+ to act - by then, significant damage may be done
Common Mistakes:
1. Ignoring stalling days: They're subtle but significant 2. Resetting count manually: Trust the 25-day expiration rule 3. Using wrong volume data: Must compare day-to-day, not vs average 4. Mixing indices: Count separately for S&P 500 and NASDAQ 5. Ignoring context: 4 distribution days in a strong uptrend is different from 4 in a range-bound market
Historical Market Top Analysis
Overview
Analysis of major market tops through the lens of the 6-component Market Top Detector framework. These historical cases provide calibration data for the scoring system.
2000: Dot-Com Bubble Top (March 2000)
Timeline
- S&P 500 Peak: March 24, 2000 (1,527.46)
- NASDAQ Peak: March 10, 2000 (5,048.62)
- Subsequent Decline: S&P -49%, NASDAQ -78% over 2.5 years
Component Signals (Estimated Scores)
| Component | Signal Level | Est. Score | Notes |
|---|---|---|---|
| Distribution Days | Very High | 90-100 | Heavy distribution throughout March 2000 |
| Leading Stocks | Critical | 85-95 | Internet stocks peaked weeks before indices |
| Defensive Rotation | Strong | 70-80 | Value/defensive outperformed for months |
| Breadth | Critical Divergence | 80-90 | Advance/Decline line peaked in April 1998 |
| Index Technical | Breaking Down | 70-80 | NASDAQ broke 50DMA first |
| Sentiment | Extreme | 90-100 | IPO mania, "new paradigm" narrative |
Estimated Composite: 82-92 (Critical)
Key Lesson
Breadth divergence was the earliest signal (nearly 2 YEARS before the top). Distribution days and leading stock breakdown were the final confirmation signals. By the time all 6 components were flashing red, the decline had already begun.
---
2007: Financial Crisis Top (October 2007)
Timeline
- S&P 500 Peak: October 9, 2007 (1,565.15)
- Subsequent Decline: -57% over 17 months (March 2009 low)
Component Signals (Estimated Scores)
| Component | Signal Level | Est. Score | Notes |
|---|---|---|---|
| Distribution Days | High | 75-85 | Multiple distribution clusters in Sept-Oct 2007 |
| Leading Stocks | High | 70-80 | Homebuilders (ITB) peaked in Feb 2006, financials (XLF) peaked in June 2007 |
| Defensive Rotation | Moderate | 55-65 | Utilities and healthcare outperforming |
| Breadth | Diverging | 60-70 | NYSE advance/decline line peaked July 2007 |
| Index Technical | Weakening | 50-60 | S&P still near highs but internal structure deteriorating |
| Sentiment | Elevated | 55-65 | VIX was very low (sub-12) for extended period in 2006-2007 |
Estimated Composite: 62-72 (Red Zone)
Key Lesson
The 2007 top was a slow-motion process. Leading sector (financials/housing) peaked 12-18 months before the S&P 500. The composite would have been in the "Orange" zone for months before reaching "Red." This top rewarded patience in reducing exposure rather than a single binary call.
---
2018: Q4 Sell-Off (September-December 2018)
Timeline
- S&P 500 Peak: September 20, 2018 (2,930.75)
- Subsequent Decline: -20% (correction, not bear market)
- Recovery: V-bottom in December 2018, new highs by April 2019
Component Signals (Estimated Scores)
| Component | Signal Level | Est. Score | Notes |
|---|---|---|---|
| Distribution Days | Moderate | 55-65 | Distribution accumulated through October |
| Leading Stocks | Moderate | 50-60 | FAANG stocks peaked at different times (FB in July, AMZN in September) |
| Defensive Rotation | Moderate | 45-55 | Utilities outperformed in Q3 |
| Breadth | Mild Divergence | 40-50 | Breadth narrowing but not extreme |
| Index Technical | Weakening | 55-65 | 50DMA crossed below 200DMA in December |
| Sentiment | Mixed | 35-45 | VIX was elevated but not extreme |
Estimated Composite: 47-57 (Orange Zone)
Key Lesson
This was a classic "correction" case (not a bear market). The composite correctly would have been in Orange zone - enough to warrant profit-taking but not full defensive posture. The rapid V-recovery demonstrated why the composite avoids going to "Critical" without extreme readings across multiple components.
---
2022: Fed Tightening Bear Market (January 2022)
Timeline
- S&P 500 Peak: January 3, 2022 (4,796.56)
- NASDAQ Peak: November 19, 2021 (16,057.44)
- Subsequent Decline: S&P -25%, NASDAQ -33%
Component Signals (Estimated Scores at January 2022)
| Component | Signal Level | Est. Score | Notes |
|---|---|---|---|
| Distribution Days | High | 75-90 | Heavy distribution in late December 2021 and early January 2022 |
| Leading Stocks | Critical | 80-90 | ARKK peaked Feb 2021 (11 months early), many growth stocks -50%+ from highs |
| Defensive Rotation | Strong | 65-75 | XLU, XLP outperforming strongly |
| Breadth | Severe Divergence | 70-80 | NASDAQ new highs/new lows diverging sharply |
| Index Technical | Breaking | 60-70 | NASDAQ broke 200DMA, S&P testing key levels |
| Sentiment | Elevated | 50-60 | Margin debt at record highs, crypto speculation |
Estimated Composite: 68-78 (Red Zone)
Key Lesson
ARKK (leading stock proxy) peaked 11 months before S&P 500. This is a textbook Minervini signal - leading stocks deteriorate long before the index. By January 2022, the composite would have been solidly in the Red zone, correctly signaling aggressive profit-taking. The 2022 case validates the "Leading Stock Health" component as an early warning signal.
---
Cross-Case Patterns
What All Tops Had in Common:
1. Distribution days clustered (not spread out) in the weeks before the decline 2. Leading stocks peaked before the index (by weeks to months) 3. Breadth diverged from index price (the rally became increasingly narrow) 4. Defensive rotation accelerated as the top formed
What Varied:
1. Severity: Ranged from -20% correction (2018) to -78% NASDAQ crash (2000) 2. Speed: 2018 was a fast decline (3 months), 2000-2002 was slow (2.5 years) 3. Recovery time: 2018 recovered in 4 months, 2000 took 7 years (NASDAQ) 4. Breadth divergence timing: 2000 had 2-year divergence, 2022 had months
Scoring Calibration:
- Orange Zone (41-60): Correction likely (2018-type, -10-20%)
- Red Zone (61-80): Major decline likely (2007, 2022-type, -20-35%)
- Critical (81-100): Extreme bubble top (2000-type, -40%+)
Important Caveat:
Historical calibration is imperfect. Each market environment is unique. The composite score should be used as one input into investment decisions, not as a definitive prediction. Markets can remain in "warning" zones for extended periods before actually declining.
---
Pattern: Leading Stocks as Early Warning
From the Monty article:
弱気相場の初期段階では、特定の主導株が下降トレンドに抵抗するかのように強く、
上昇できるという印象を与えます。
This pattern appeared in every historical top:
- 2000: Blue-chip tech (MSFT, INTC) held up while dot-coms crashed
- 2007: Energy sector rallied while financials imploded
- 2022: AAPL, MSFT relatively stable while ARKK stocks crashed
- Current analog: Some mega-caps hold while growth/speculative names deteriorate
This is why Component 2 (Leading Stock Health) uses a broad basket of growth ETFs rather than just the S&P 500 components - it captures the deterioration that indices mask.
Market Top Detection Methodology
Overview
This framework integrates three proven approaches to market top detection, each targeting different aspects of institutional behavior that precede significant market declines (10-20% corrections).
The Three Pillars
Pillar 1: O'Neil - Distribution Day Accumulation
Source: William O'Neil, "How to Make Money in Stocks"
Core Concept: Institutional investors (mutual funds, hedge funds, pensions) cannot sell their large positions in a single day. Their selling creates a distinctive pattern: the index declines on higher-than-previous-day volume. When these "distribution days" accumulate, it signals that smart money is systematically exiting.
Distribution Day Definition:
- Index declines >= 0.2% from previous close
- Volume is higher than previous day's volume
- Both conditions must be met simultaneously
Stalling Day Definition:
- Volume increases from previous day
- But price gain is minimal (< 0.1%)
- Indicates institutional selling into strength
- Counted at half weight (0.5 distribution days)
25-Day Window:
- Distribution days expire after 25 trading days
- Only the rolling 25-day count matters
- This prevents old events from distorting current assessment
O'Neil's Warning Thresholds:
- 4-5 distribution days in 25 trading days = "market under pressure"
- 6+ distribution days = "distribution is heavy, protect capital"
- Combined with stalling days for full picture
Pillar 2: Minervini - Leading Stock Deterioration
Source: Mark Minervini, "Trade Like a Stock Market Wizard" / "Think & Trade Like a Champion"
Core Concept: Market tops do not appear suddenly. They develop through a process where leading stocks - the strongest performers of the previous rally - begin breaking down before the major indices. This is because institutional investors sell their biggest winners first to lock in profits.
Key Observation from Monty Article:
"弱気相場の初期段階では、特定の主導株が下降トレンドに抵抗するかのように強く、上昇できるという印象を与えます。"
(In the early stages of a bear market, certain leading stocks appear to resist the downtrend, giving the impression they can still rise.)
Detection Method: Using a basket of growth/innovation ETFs as proxy for market leadership:
- ARKK (Innovation), WCLD (Cloud), IGV (Software)
- XBI (Biotech), SOXX/SMH (Semiconductors)
- KWEB (China Tech), TAN (Solar)
Deterioration Signals per ETF: 1. Distance from 52-week high (>10% = concerning, >25% = bear territory) 2. Price below 50-day moving average 3. Price below 200-day moving average 4. Formation of lower highs pattern
Amplification Rule: When 60%+ of leading ETFs show deterioration, the signal is amplified by 1.3x. This reflects the systemic nature of leadership breakdown.
Pillar 3: Monty - Defensive Sector Rotation
Source: monty-trader.com "米国株 株式相場の天井の見極め方と下落局面でやるべきこと"
Core Concept: Before a market top, capital flows from offensive/growth sectors into defensive/value sectors. This "rotation" occurs because institutional investors are becoming defensive while maintaining their equity allocation. It's a critical early warning signal.
Defensive Sectors (Safety Seekers):
- XLU (Utilities) - stable cash flows, bond-like
- XLP (Consumer Staples) - recession-resistant demand
- XLV (Healthcare) - non-discretionary spending
- VNQ (Real Estate) - income-focused
Offensive Sectors (Growth/Risk):
- XLK (Technology) - growth-dependent
- XLC (Communication Services) - ad-spend sensitive
- XLY (Consumer Discretionary) - economically sensitive
- QQQ (NASDAQ 100) - tech-heavy growth proxy
Signal: When defensive sectors outperform offensive sectors over a 20-day rolling period, capital is flowing defensively. A relative performance spread of +3% or more is a strong warning signal.
---
Supplementary Components
Component 4: Market Breadth Divergence
Concept: When the index makes new highs but fewer stocks participate, the rally is narrowing and vulnerable. This "divergence" between index price and market breadth is a classic top signal.
Key Metric: Percentage of S&P 500 stocks above their 200-day moving average.
- Healthy market: >70% of stocks above 200DMA
- Warning zone: 50-70% with index near highs
- Critical: <50% with index at highs
Important Nuance: Breadth divergence is most meaningful when the index is near its 52-week high (within -5%). If the index has already corrected significantly, weak breadth is expected and less informative.
Component 5: Index Technical Condition
Moving Average Structure:
- Healthy: Price > 21 EMA > 50 EMA > 200 SMA
- Deteriorating: Any of these relationships inverting
- Bearish: Price below all major moving averages
Pattern Recognition:
- Failed Rally: Price bounces but fails to exceed recent peak
- Lower Highs: Sequential swing highs declining
- Gap Downs on Volume: Institutional panic selling
Component 6: Sentiment & Speculation
VIX (Fear Index):
- <12: Extreme complacency (warning)
- 12-16: Low volatility (mild warning)
- 16-20: Normal
- >25: Fear elevated (top less likely, correction may be underway)
Put/Call Ratio:
- <0.60: Extreme call buying (maximum complacency)
- 0.60-0.70: Elevated optimism
- 0.70-0.80: Mildly bullish
- >0.80: Healthy caution
VIX Term Structure:
- Steep Contango: Market expects calm (complacency signal)
- Backwardation: Hedging demand elevated (fear present)
---
Follow-Through Day (FTD) Monitor
O'Neil's concept for bottom confirmation, relevant when composite score > 40:
1. Rally Attempt Day: First day the index closes up after a decline 2. Counting begins: Day 1 of potential new uptrend 3. Follow-Through Day: On days 4-7 of rally attempt, a strong gain (1.5%+) on higher volume than previous day 4. Significance: "The most powerful uptrends usually begin with a Follow-Through Day on day 4-7"
False FTD Rate: Approximately 25% of FTDs fail. Multiple FTDs increase confidence.
---
Scoring Philosophy
Why Weighted Composite?
No single indicator perfectly predicts market tops. Each pillar captures a different dimension:
- Distribution Days: Direct measurement of institutional selling
- Leading Stocks: Quality of market leadership
- Defensive Rotation: Institutional positioning shift
- Breadth: Market participation health
- Technicals: Price structure integrity
- Sentiment: Psychological extremes
Weight Rationale
- Distribution Days (25%): Most direct measure of institutional behavior
- Leading Stocks (20%): Minervini's strongest conviction signal
- Defensive Rotation (15%): Monty's key differentiator
- Breadth (15%): Classic confirmation signal
- Technicals (15%): Structural integrity check
- Sentiment (10%): Context and extremes (less reliable alone)
Calibration Principle
The scoring is calibrated so that:
- A "normal" healthy market scores 10-20
- An "early warning" market scores 25-40
- A market in the "initial to middle stage of top formation" scores 40-55
- A clear top formation scores 60-80
- A completed top with breakdown scores 80+
#!/usr/bin/env python3
"""
Breadth CSV Client - Fetch 200DMA breadth from TraderMonty's public CSV.
Lightweight client for auto-fetching S&P 500 breadth data (% above 200DMA)
from the market-breadth-analysis GitHub Pages CSV. No API key required.
Data source:
https://tradermonty.github.io/market-breadth-analysis/market_breadth_data.csv
"""
import csv
import io
from datetime import datetime
from typing import Optional
import requests
DEFAULT_DETAIL_URL = "https://tradermonty.github.io/market-breadth-analysis/market_breadth_data.csv"
TIMEOUT = 30
def fetch_breadth_200dma(url: str = DEFAULT_DETAIL_URL) -> Optional[dict]:
"""
Fetch the latest 200DMA breadth value from TraderMonty CSV.
Returns:
Dict with keys: value (0-100%), date (str), is_fresh (bool),
days_old (int), source (str).
None if fetch fails or CSV is empty.
"""
rows = _fetch_detail_csv(url)
if not rows:
return None
latest = rows[-1]
raw_value = latest["Breadth_Index_Raw"]
value = round(raw_value * 100, 2)
date_str = latest["Date"]
freshness = _check_data_freshness(date_str)
return {
"value": value,
"date": date_str,
"is_fresh": freshness["is_fresh"],
"days_old": freshness["days_old"],
"source": "TraderMonty CSV",
}
def _fetch_detail_csv(url: str) -> list:
"""Fetch and parse the detail CSV. Returns rows sorted by date ascending."""
try:
resp = requests.get(url, timeout=TIMEOUT)
resp.raise_for_status()
except (requests.RequestException, OSError, ValueError):
return []
reader = csv.DictReader(io.StringIO(resp.text))
rows = []
for raw_row in reader:
try:
row = _parse_detail_row(raw_row)
rows.append(row)
except (ValueError, KeyError):
continue
if not rows:
return []
rows.sort(key=lambda r: r["Date"])
return rows
def _check_data_freshness(date_str: str, max_stale_days: int = 5) -> dict:
"""Check if data date is within acceptable freshness window.
Uses business days so Friday data remains fresh on Monday.
Future dates return is_fresh=False, days_old=None.
"""
from utils import count_business_days
try:
data_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return {"is_fresh": False, "days_old": None}
today = datetime.now().date()
# Reject future dates
if data_date > today:
return {"is_fresh": False, "days_old": None}
biz_days = count_business_days(data_date, today)
return {
"is_fresh": biz_days <= max_stale_days,
"days_old": biz_days,
}
def _parse_detail_row(raw: dict) -> dict:
"""Convert a raw CSV row dict to properly typed values."""
return {
"Date": raw["Date"].strip(),
"S&P500_Price": float(raw["S&P500_Price"]),
"Breadth_Index_Raw": float(raw["Breadth_Index_Raw"]),
"Breadth_Index_200MA": float(raw["Breadth_Index_200MA"]),
"Breadth_Index_8MA": float(raw["Breadth_Index_8MA"]),
"Breadth_200MA_Trend": int(raw["Breadth_200MA_Trend"]),
"Bearish_Signal": _parse_bool(raw["Bearish_Signal"]),
"Is_Peak": _parse_bool(raw["Is_Peak"]),
"Is_Trough": _parse_bool(raw["Is_Trough"]),
"Is_Trough_8MA_Below_04": _parse_bool(raw["Is_Trough_8MA_Below_04"]),
}
def _parse_bool(val: str) -> bool:
"""Parse boolean from CSV string."""
return val.strip().lower() in ("true", "1", "yes")
"""Market Top Detector Calculator Modules"""
from .breadth_calculator import calculate_breadth_divergence
from .defensive_rotation_calculator import calculate_defensive_rotation
from .distribution_day_calculator import calculate_distribution_days
from .index_technical_calculator import calculate_index_technical
from .leading_stock_calculator import calculate_leading_stock_health
from .math_utils import calc_ema, calc_sma
from .sentiment_calculator import calculate_sentiment
__all__ = [
"calculate_distribution_days",
"calculate_leading_stock_health",
"calculate_defensive_rotation",
"calculate_breadth_divergence",
"calculate_index_technical",
"calculate_sentiment",
"calc_ema",
"calc_sma",
]
#!/usr/bin/env python3
"""
Component 4: Market Breadth Divergence (Weight: 15%)
Data source: CLI arguments (collected via WebSearch before script execution)
- breadth_200dma: % of S&P 500 stocks above 200DMA
- breadth_50dma: % of S&P 500 stocks above 50DMA
Scoring logic:
When index is near highs (within -5% of 52-week high):
200DMA_above < 40% -> 100 (Critical divergence)
200DMA_above < 50% -> 80
200DMA_above < 60% -> 60
200DMA_above < 70% -> 35
200DMA_above >= 70% -> 10
When index is NOT near highs (> -5%):
Score is halved (breadth divergence less meaningful when index is correcting)
50DMA breadth is used as a supplementary signal.
"""
from typing import Optional
def calculate_breadth_divergence(
breadth_200dma: Optional[float],
breadth_50dma: Optional[float],
index_distance_from_high_pct: float,
) -> dict:
"""
Calculate market breadth divergence score.
Args:
breadth_200dma: % of S&P 500 stocks above 200DMA (0-100)
breadth_50dma: % of S&P 500 stocks above 50DMA (0-100)
index_distance_from_high_pct: S&P 500 distance from 52-week high (negative %)
Returns:
Dict with score (0-100), signal, details
"""
if breadth_200dma is None:
return {
"score": 50,
"signal": "NO DATA: Breadth data not provided (neutral default)",
"data_available": False,
"breadth_200dma": None,
"breadth_50dma": breadth_50dma,
"index_near_highs": False,
"divergence_detected": False,
}
index_near_highs = index_distance_from_high_pct >= -5.0
# Primary score based on 200DMA breadth
raw_score = _score_200dma_breadth(breadth_200dma)
# Halve score if index is NOT near highs
if not index_near_highs:
raw_score = raw_score * 0.5
# 50DMA breadth supplementary adjustment
if breadth_50dma is not None:
if breadth_50dma < 30 and index_near_highs:
raw_score = min(100, raw_score + 10) # Extra warning
elif breadth_50dma > 70:
raw_score = max(0, raw_score - 5) # Slight relief
score = round(min(100, max(0, raw_score)))
# Detect divergence
divergence_detected = index_near_highs and breadth_200dma < 60
if score >= 80:
signal = "CRITICAL: Severe breadth divergence - index at highs but breadth collapsing"
elif score >= 60:
signal = "WARNING: Significant breadth divergence"
elif score >= 35:
signal = "CAUTION: Mild breadth weakness"
elif divergence_detected:
signal = "WATCH: Some breadth deterioration despite index strength"
else:
signal = "HEALTHY: Breadth supports index level"
return {
"score": score,
"signal": signal,
"data_available": True,
"breadth_200dma": breadth_200dma,
"breadth_50dma": breadth_50dma,
"index_near_highs": index_near_highs,
"index_distance_from_high_pct": round(index_distance_from_high_pct, 1),
"divergence_detected": divergence_detected,
}
def _score_200dma_breadth(breadth_200dma: float) -> int:
"""Score based on % of stocks above 200DMA.
Calibrated thresholds (linearly interpolated):
< 40% -> 100 (Critical divergence)
40-50% -> 100 to 55 (Severe)
50-60% -> 55 to 30 (Moderate)
60-70% -> 30 to 5 (Mild - still relatively healthy)
>= 70% -> 5 (Healthy breadth)
Calibration note: 62.26% should score ~24 (healthy/green signal)
"""
if breadth_200dma < 40:
return 100
elif breadth_200dma < 50:
return round(100 - (breadth_200dma - 40) / 10 * 45)
elif breadth_200dma < 60:
return round(55 - (breadth_200dma - 50) / 10 * 25)
elif breadth_200dma < 70:
return round(30 - (breadth_200dma - 60) / 10 * 25)
else:
return 5
#!/usr/bin/env python3
"""
Component 3: Defensive Sector Rotation (Weight: 15%)
Compares defensive ETF performance vs offensive/growth ETF performance
using multi-period weighted analysis: 10d(0.2) + 20d(0.5) + 40d(0.3).
Defensive: XLU, XLP, XLV, VNQ
Offensive: XLK, XLC, XLY, QQQ
Scoring (defensive_return - offensive_return):
+5.0% or more -> 100 (Strong rotation into defensives)
+3.0% or more -> 80
+1.5% or more -> 60
+0.5% or more -> 40
+0.0% or more -> 20
Negative -> 0 (Growth leading = healthy)
Multi-period confirmation:
All periods defensive -> "confirmed" (max 100)
Some periods only -> "unconfirmed" (capped at 80)
"""
from typing import Optional
DEFENSIVE_ETFS = ["XLU", "XLP", "XLV", "VNQ"]
OFFENSIVE_ETFS = ["XLK", "XLC", "XLY", "QQQ"]
MULTI_PERIOD_WEIGHTS = {10: 0.2, 20: 0.5, 40: 0.3}
def _calc_return(symbol_hist: list[dict], days: int) -> Optional[float]:
if not symbol_hist or len(symbol_hist) < days + 1:
return None
recent = symbol_hist[0].get("close", symbol_hist[0].get("adjClose", 0))
past = symbol_hist[days].get("close", symbol_hist[days].get("adjClose", 0))
if past == 0:
return None
return (recent - past) / past * 100
def _compute_period_rotation(historical: dict[str, list[dict]], lookback: int) -> Optional[dict]:
"""Compute defensive vs offensive rotation for a single period."""
def_returns = []
off_returns = []
for symbol in DEFENSIVE_ETFS:
hist = historical.get(symbol, [])
ret = _calc_return(hist, lookback)
if ret is not None:
def_returns.append(ret)
for symbol in OFFENSIVE_ETFS:
hist = historical.get(symbol, [])
ret = _calc_return(hist, lookback)
if ret is not None:
off_returns.append(ret)
if not def_returns or not off_returns:
return None
def_avg = sum(def_returns) / len(def_returns)
off_avg = sum(off_returns) / len(off_returns)
relative = def_avg - off_avg
score = _score_rotation(relative)
return {
"lookback": lookback,
"defensive_avg": round(def_avg, 2),
"offensive_avg": round(off_avg, 2),
"relative": round(relative, 2),
"score": score,
"defensive_leading": relative > 0,
}
def calculate_defensive_rotation(historical: dict[str, list[dict]], lookback: int = 20) -> dict:
"""
Calculate defensive vs offensive sector rotation score using multi-period analysis.
Args:
historical: Dict of symbol -> list of daily OHLCV (most recent first, 50+ days)
lookback: Primary lookback period (default 20, used for backward compat)
Returns:
Dict with score (0-100), relative_performance, multi_period details
"""
# Track fetch success
all_etfs = DEFENSIVE_ETFS + OFFENSIVE_ETFS
total_attempted = len(all_etfs)
fetch_successes = 0
def_details = {}
off_details = {}
for symbol in DEFENSIVE_ETFS:
hist = historical.get(symbol, [])
ret = _calc_return(hist, lookback)
if ret is not None:
def_details[symbol] = round(ret, 2)
fetch_successes += 1
for symbol in OFFENSIVE_ETFS:
hist = historical.get(symbol, [])
ret = _calc_return(hist, lookback)
if ret is not None:
off_details[symbol] = round(ret, 2)
fetch_successes += 1
fetch_success_rate = fetch_successes / total_attempted if total_attempted > 0 else 0.0
if not def_details or not off_details:
return {
"score": 50,
"signal": "INSUFFICIENT DATA (neutral default)",
"data_available": False,
"relative_performance": 0,
"defensive_avg_return": 0,
"offensive_avg_return": 0,
"defensive_details": def_details,
"offensive_details": off_details,
"lookback_days": lookback,
"fetch_success_rate": round(fetch_success_rate, 2),
}
# Multi-period analysis
period_results = {}
weighted_score = 0.0
total_weight = 0.0
all_defensive = True
periods_computed = 0
for period, weight in MULTI_PERIOD_WEIGHTS.items():
result = _compute_period_rotation(historical, period)
if result is not None:
period_results[period] = result
weighted_score += result["score"] * weight
total_weight += weight
periods_computed += 1
if not result["defensive_leading"]:
all_defensive = False
else:
all_defensive = False
# Fall back to single-period if multi-period unavailable
if total_weight > 0:
raw_score = weighted_score / total_weight
else:
# Fallback: single period
def_avg = sum(def_details.values()) / len(def_details)
off_avg = sum(off_details.values()) / len(off_details)
relative = def_avg - off_avg
raw_score = _score_rotation(relative)
# Multi-period confirmation
if periods_computed >= 2 and all_defensive:
confirmation = "confirmed"
score = round(min(100, raw_score))
elif periods_computed >= 2:
confirmation = "unconfirmed"
score = round(min(80, raw_score))
else:
confirmation = "single_period"
score = round(min(100, raw_score))
score = max(0, score)
# Primary period relative performance (for backward compat)
primary = period_results.get(lookback)
if primary:
relative = primary["relative"]
def_avg = primary["defensive_avg"]
off_avg = primary["offensive_avg"]
else:
def_avg = sum(def_details.values()) / len(def_details)
off_avg = sum(off_details.values()) / len(off_details)
relative = def_avg - off_avg
if score >= 80:
signal = "CRITICAL: Strong defensive rotation"
elif score >= 60:
signal = "WARNING: Defensive outperformance"
elif score >= 40:
signal = "CAUTION: Mild defensive rotation"
elif score >= 20:
signal = "MIXED: Slight defensive tilt"
else:
signal = "HEALTHY: Growth leading"
return {
"score": score,
"signal": signal,
"data_available": fetch_success_rate >= 0.75,
"relative_performance": round(relative, 2),
"defensive_avg_return": round(def_avg, 2),
"offensive_avg_return": round(off_avg, 2),
"defensive_details": def_details,
"offensive_details": off_details,
"lookback_days": lookback,
"fetch_success_rate": round(fetch_success_rate, 2),
"confirmation": confirmation,
"multi_period": period_results,
}
def _score_rotation(relative: float) -> int:
"""Convert relative performance (defensive - offensive) to 0-100 score"""
if relative >= 5.0:
return 100
elif relative >= 3.0:
return round(80 + (relative - 3.0) / 2.0 * 20)
elif relative >= 1.5:
return round(60 + (relative - 1.5) / 1.5 * 20)
elif relative >= 0.5:
return round(40 + (relative - 0.5) / 1.0 * 20)
elif relative >= 0.0:
return round(20 + relative / 0.5 * 20)
else:
if relative >= -2.0:
return round(max(0, 20 + relative / 2.0 * 20))
return 0
#!/usr/bin/env python3
"""
Component 1: Distribution Day Count (Weight: 25%)
O'Neil's Distribution Day Rules:
- Distribution Day: Index drops >= 0.2% on higher volume than previous day
- Stalling Day: Volume increases but price gain < 0.1% (half weight)
- Days expire after 25 trading days
- Count the higher of S&P 500 or NASDAQ
Scoring:
6+ distribution days -> 100 (Critical)
5 days -> 90
4 days -> 75 (O'Neil's warning threshold)
3 days -> 55
2 days -> 30
1 day -> 15
0 days -> 0
"""
def calculate_distribution_days(sp500_history: list[dict], nasdaq_history: list[dict]) -> dict:
"""
Calculate distribution day count for S&P 500 and NASDAQ.
Args:
sp500_history: List of daily OHLCV dicts (most recent first), at least 30 days
nasdaq_history: List of daily OHLCV dicts (most recent first), at least 30 days
Returns:
Dict with score (0-100), distribution_days, stalling_days, details
"""
sp500_result = _count_distribution_days(sp500_history, "S&P 500")
nasdaq_result = _count_distribution_days(nasdaq_history, "NASDAQ")
# Use the higher (worse) effective count
sp500_effective = sp500_result["distribution_days"] + 0.5 * sp500_result["stalling_days"]
nasdaq_effective = nasdaq_result["distribution_days"] + 0.5 * nasdaq_result["stalling_days"]
if sp500_effective >= nasdaq_effective:
primary = sp500_result
primary_name = "S&P 500"
else:
primary = nasdaq_result
primary_name = "NASDAQ"
effective_count = max(sp500_effective, nasdaq_effective)
raw_score = _score_distribution_days(effective_count)
# Clustering analysis: check if distribution days are concentrated recently
primary_details = primary["details"]
clustering = _calculate_clustering_factor(primary_details)
clustering_applied = effective_count >= 2 and clustering["factor"] > 0.5
if clustering_applied:
score = min(100, round(raw_score * 1.15))
else:
score = raw_score
# Build signal description
if effective_count >= 5:
signal = "CRITICAL: Heavy distribution detected"
elif effective_count >= 4:
signal = "WARNING: O'Neil's threshold reached"
elif effective_count >= 3:
signal = "CAUTION: Moderate distribution"
elif effective_count >= 1:
signal = "MINOR: Some distribution present"
else:
signal = "CLEAR: No distribution"
return {
"score": score,
"raw_score": raw_score,
"effective_count": effective_count,
"signal": signal,
"clustering": clustering,
"clustering_applied": clustering_applied,
"primary_index": primary_name,
"sp500": {
"distribution_days": sp500_result["distribution_days"],
"stalling_days": sp500_result["stalling_days"],
"effective_count": sp500_effective,
"details": sp500_result["details"],
},
"nasdaq": {
"distribution_days": nasdaq_result["distribution_days"],
"stalling_days": nasdaq_result["stalling_days"],
"effective_count": nasdaq_effective,
"details": nasdaq_result["details"],
},
}
def _count_distribution_days(history: list[dict], index_name: str) -> dict:
"""Count distribution and stalling days in the last 25 trading days"""
if not history or len(history) < 2:
return {"distribution_days": 0, "stalling_days": 0, "details": []}
# We need at least 26 days to check 25 days of change
# history[0] = most recent, history[1] = day before, etc.
window = min(25, len(history) - 1) # 25 trading day window
distribution_days = 0
stalling_days = 0
details = []
for i in range(window):
today = history[i]
yesterday = history[i + 1]
today_close = today.get("close", today.get("adjClose", 0))
yesterday_close = yesterday.get("close", yesterday.get("adjClose", 0))
today_volume = today.get("volume", 0)
yesterday_volume = yesterday.get("volume", 0)
if yesterday_close == 0 or yesterday_volume == 0:
continue
pct_change = (today_close - yesterday_close) / yesterday_close * 100
volume_increase = today_volume > yesterday_volume
date = today.get("date", f"day-{i}")
# Distribution day: price drops >= 0.2% AND volume increases
if pct_change <= -0.2 and volume_increase:
distribution_days += 1
details.append(
{
"date": date,
"type": "distribution",
"pct_change": round(pct_change, 2),
"volume_change": round((today_volume / yesterday_volume - 1) * 100, 1),
"window_index": i,
}
)
# Stalling day: volume increases but price gain < 0.1%
elif volume_increase and 0 <= pct_change < 0.1:
stalling_days += 1
details.append(
{
"date": date,
"type": "stalling",
"pct_change": round(pct_change, 2),
"volume_change": round((today_volume / yesterday_volume - 1) * 100, 1),
"window_index": i,
}
)
return {
"distribution_days": distribution_days,
"stalling_days": stalling_days,
"details": details,
}
def _calculate_clustering_factor(details: list) -> dict:
"""
Calculate how clustered distribution/stalling events are in recent days.
Looks at window_index of events: index 0-4 = most recent 5 days.
Returns factor = recent_count / total_count (0.0-1.0).
"""
if not details:
return {"factor": 0.0, "recent_count": 0, "total_count": 0}
total_count = len(details)
recent_count = sum(1 for d in details if d.get("window_index", 99) < 5)
factor = recent_count / total_count if total_count > 0 else 0.0
return {
"factor": round(factor, 2),
"recent_count": recent_count,
"total_count": total_count,
}
def _score_distribution_days(effective_count: float) -> int:
"""Convert effective distribution day count to 0-100 score"""
if effective_count >= 6:
return 100
elif effective_count >= 5:
return 90
elif effective_count >= 4:
return 75
elif effective_count >= 3:
return 55
elif effective_count >= 2:
return 30
elif effective_count >= 1:
return 15
else:
return 0
#!/usr/bin/env python3
"""
Component 5: Index Technical Condition (Weight: 15%)
Evaluates technical health of S&P 500 and NASDAQ.
Uses shared historical data from Component 1 (no additional API calls).
Checkpoints (per index, S&P 500 + NASDAQ):
1. Price < 21 EMA (short-term weakness) -> +8pt
2. Price < 50 EMA (medium-term breakdown) -> +12pt
3. 21 EMA < 50 EMA (MA bearish crossover) -> +10pt
4. Price < 200 SMA (long-term trend breakdown) -> +15pt
5. Failed rally pattern -> +10pt
6. Lower highs pattern -> +10pt
7. Gap down on volume -> +10pt
Max per index: ~75 points (not all conditions fire at once in early stages)
Final: average of both indices, scaled to 0-100
"""
from typing import Optional
from calculators.math_utils import calc_ema
def calculate_index_technical(
sp500_history: list[dict],
nasdaq_history: list[dict],
sp500_quote: Optional[dict] = None,
nasdaq_quote: Optional[dict] = None,
) -> dict:
"""
Calculate index technical condition score.
Args:
sp500_history: S&P 500 daily OHLCV (most recent first, 250+ days preferred)
nasdaq_history: NASDAQ daily OHLCV (most recent first, 250+ days preferred)
sp500_quote: Current S&P 500 quote (optional, for real-time price)
nasdaq_quote: Current NASDAQ/QQQ quote (optional)
Returns:
Dict with score (0-100), sp500_details, nasdaq_details, signal
"""
sp500_result = _evaluate_index("S&P 500", sp500_history, sp500_quote)
nasdaq_result = _evaluate_index("NASDAQ", nasdaq_history, nasdaq_quote)
# Average only indices with available data
scores = []
if sp500_result.get("data_available", False):
scores.append(sp500_result["raw_score"])
if nasdaq_result.get("data_available", False):
scores.append(nasdaq_result["raw_score"])
if not scores:
final_score = 50 # Neutral when no data
signal = "NO DATA: Index data unavailable"
return {
"score": final_score,
"signal": signal,
"sp500": sp500_result,
"nasdaq": nasdaq_result,
"data_available": False,
}
avg_score = sum(scores) / len(scores)
final_score = round(min(100, max(0, avg_score)))
if final_score >= 70:
signal = "CRITICAL: Major technical breakdown"
elif final_score >= 50:
signal = "WARNING: Significant technical deterioration"
elif final_score >= 35:
signal = "CAUTION: Short-term weakness detected"
elif final_score >= 15:
signal = "MIXED: Minor technical concerns"
else:
signal = "HEALTHY: Technical structure intact"
return {
"score": final_score,
"signal": signal,
"sp500": sp500_result,
"nasdaq": nasdaq_result,
"data_available": True,
}
def _evaluate_index(name: str, history: list[dict], quote: Optional[dict] = None) -> dict:
"""Evaluate a single index's technical condition"""
if not history or len(history) < 21:
return {"raw_score": 0, "flags": ["Insufficient data"], "mas": {}, "data_available": False}
closes = [d.get("close", d.get("adjClose", 0)) for d in history]
highs = [d.get("high", d.get("close", 0)) for d in history]
[d.get("low", d.get("close", 0)) for d in history]
volumes = [d.get("volume", 0) for d in history]
# Current price (from quote or most recent close)
if quote:
price = quote.get("price", closes[0])
else:
price = closes[0]
score = 0
flags = []
mas = {}
# Calculate moving averages
ema21 = calc_ema(closes, 21)
mas["ema21"] = round(ema21, 2)
if len(closes) >= 50:
ema50 = calc_ema(closes, 50)
mas["ema50"] = round(ema50, 2)
else:
ema50 = None
if len(closes) >= 200:
sma200 = sum(closes[:200]) / 200
mas["sma200"] = round(sma200, 2)
else:
sma200 = None
# Check 1: Price < 21 EMA (short-term weakness)
if price < ema21:
score += 8
pct_below = (price - ema21) / ema21 * 100
flags.append(f"Below 21 EMA ({pct_below:+.1f}%)")
# Check 2: Price < 50 EMA (medium-term breakdown)
if ema50 and price < ema50:
score += 12
pct_below = (price - ema50) / ema50 * 100
flags.append(f"Below 50 EMA ({pct_below:+.1f}%)")
# Check 3: 21 EMA < 50 EMA (bearish crossover)
if ema50 and ema21 < ema50:
score += 10
flags.append("21 EMA < 50 EMA (bearish crossover)")
# Check 4: Price < 200 SMA (long-term trend breakdown)
if sma200 and price < sma200:
score += 15
pct_below = (price - sma200) / sma200 * 100
flags.append(f"Below 200 SMA ({pct_below:+.1f}%)")
# Check 5: Failed rally pattern
if _detect_failed_rally(closes, volumes):
score += 10
flags.append("Failed rally pattern detected")
# Check 6: Lower highs pattern (20 day)
if _detect_lower_highs(highs, lookback=20):
score += 10
flags.append("Lower highs pattern (20 day)")
# Check 7: Gap down on volume
if _detect_gap_down(history):
score += 10
flags.append("Recent gap down on volume")
return {
"raw_score": min(100, score),
"price": round(price, 2),
"flags": flags,
"mas": mas,
"data_available": True,
}
def _detect_failed_rally(closes: list[float], volumes: list[int], lookback: int = 15) -> bool:
"""
Detect failed rally: price bounces then fails to make new high
within recent lookback period.
"""
if len(closes) < lookback:
return False
recent = closes[:lookback]
# Find the peak in recent data
peak_idx = recent.index(max(recent))
# Failed rally: peak occurred 3-10 days ago, current price is below that peak
if 3 <= peak_idx <= 10:
# Price fell from peak
drop = (recent[0] - recent[peak_idx]) / recent[peak_idx] * 100
if drop < -2.0: # Dropped >2% from recent peak
return True
return False
def _detect_lower_highs(highs: list[float], lookback: int = 20) -> bool:
"""Detect lower highs pattern in price action"""
if len(highs) < lookback:
return False
recent_highs = highs[:lookback]
# Find swing highs (local maxima)
swing_highs = []
for i in range(1, len(recent_highs) - 1):
if recent_highs[i] > recent_highs[i - 1] and recent_highs[i] > recent_highs[i + 1]:
swing_highs.append(recent_highs[i])
if len(swing_highs) < 2:
return False
# Most recent swing high lower than previous
return swing_highs[0] < swing_highs[1]
def _detect_gap_down(history: list[dict], lookback: int = 5) -> bool:
"""Detect gap down on increased volume in recent sessions"""
if len(history) < lookback + 1:
return False
for i in range(lookback):
today = history[i]
yesterday = history[i + 1]
today_open = today.get("open", 0)
yesterday_close = yesterday.get("close", yesterday.get("adjClose", 0))
today_volume = today.get("volume", 0)
yesterday_volume = yesterday.get("volume", 0)
if yesterday_close == 0 or yesterday_volume == 0:
continue
gap_pct = (today_open - yesterday_close) / yesterday_close * 100
volume_increase = today_volume > yesterday_volume * 1.1
# Gap down >= 0.5% on higher volume
if gap_pct <= -0.5 and volume_increase:
return True
return False
#!/usr/bin/env python3
"""
Component 2: Leading Stock Health (Weight: 20%)
Evaluates health of leading/growth ETFs as proxy for market leadership.
Uses ETF baskets instead of individual stocks for API efficiency.
Default ETF Basket: ARKK, WCLD, IGV, XBI, SOXX, SMH, KWEB, TAN
Dynamic mode: Selects top-N ETFs from a 20-ETF candidate pool by 52-week high proximity.
Evaluation per ETF:
- Distance from 52-week high
- Position vs 50DMA and 200DMA
- Lower highs pattern detection
Scoring: Weighted average of ETF deterioration signals.
If 60%+ ETFs are deteriorating, apply 1.3x amplification.
"""
from typing import Optional
# Default Leading/Growth ETF basket
DEFAULT_LEADING_ETFS = ["ARKK", "WCLD", "IGV", "XBI", "SOXX", "SMH", "KWEB", "TAN"]
LEADING_ETFS = DEFAULT_LEADING_ETFS # backward compat
# Expanded candidate pool for dynamic basket selection
CANDIDATE_POOL = [
"SMH",
"SOXX",
"PSI",
"SOXQ", # Semiconductors
"IGV",
"WCLD",
"CLOU",
"BUG", # Software / Cloud / Cyber
"XBI",
"ARKG", # Biotech / Genomics
"ARKK",
"ARKW",
"KOMP", # Innovation / Disruptive
"TAN",
"ICLN", # Clean Energy
"KWEB",
"FDN", # Internet / China Tech
"FINX",
"IPAY", # Fintech / Payments
"BOTZ", # Robotics / AI
]
def select_dynamic_basket(quotes: dict[str, dict], top_n: int = 10) -> list[str]:
"""
Select top-N ETFs from candidate pool by proximity to 52-week high.
Args:
quotes: Dict of symbol -> quote data with 'price' and 'yearHigh'
top_n: Number of ETFs to select (default 10)
Returns:
List of selected symbols, sorted by proximity to high (closest first).
Falls back to DEFAULT_LEADING_ETFS if fewer than 5 valid candidates.
"""
scored = []
for symbol in CANDIDATE_POOL:
q = quotes.get(symbol)
if not q:
continue
price = q.get("price", 0)
year_high = q.get("yearHigh", 0)
if year_high <= 0 or price <= 0:
continue
proximity = price / year_high # 1.0 = at high, 0.5 = 50% below
scored.append((symbol, proximity))
if len(scored) < 5:
return list(DEFAULT_LEADING_ETFS)
# Sort by proximity descending (closest to high = strongest leader)
scored.sort(key=lambda x: x[1], reverse=True)
return [s[0] for s in scored[:top_n]]
def calculate_leading_stock_health(
quotes: dict[str, dict], historical: dict[str, list[dict]], etf_list: Optional[list[str]] = None
) -> dict:
"""
Calculate leading stock health score.
Args:
quotes: Dict of symbol -> quote data (from FMP batch quote)
historical: Dict of symbol -> list of daily OHLCV (most recent first, ~60 days)
etf_list: Optional explicit list of ETF symbols to evaluate.
If None, uses quotes.keys() or DEFAULT_LEADING_ETFS.
Returns:
Dict with score (0-100), etf_details, signal, basket_mode, basket
"""
etf_scores = []
etf_details = {}
if etf_list is not None:
basket_mode = "dynamic" if etf_list != list(DEFAULT_LEADING_ETFS) else "static"
eval_list = list(etf_list)
elif quotes:
basket_mode = "static"
eval_list = list(quotes.keys())
else:
basket_mode = "static"
eval_list = list(DEFAULT_LEADING_ETFS)
total_attempted = len(eval_list)
fetch_successes = 0
partial_data_count = 0
# Minimum history bars for full analysis (50DMA calculation)
MIN_HISTORY_BARS = 50
for symbol in eval_list:
quote = quotes.get(symbol)
hist = historical.get(symbol, [])
if not quote:
continue
# Full success requires quote + sufficient history
if len(hist) >= MIN_HISTORY_BARS:
fetch_successes += 1
else:
partial_data_count += 1
detail = _evaluate_etf(symbol, quote, hist)
etf_scores.append(detail["deterioration_score"])
etf_details[symbol] = detail
fetch_success_rate = fetch_successes / total_attempted if total_attempted > 0 else 0.0
if not etf_scores:
return {
"score": 50,
"signal": "INSUFFICIENT DATA",
"etf_details": {},
"etfs_evaluated": 0,
"etfs_deteriorating": 0,
"data_available": False,
"fetch_success_rate": fetch_success_rate,
"partial_data_count": partial_data_count,
"basket_mode": basket_mode,
"basket": eval_list,
}
# Mark data as unavailable if fetch success rate is below 75%
data_available = fetch_success_rate >= 0.75
avg_deterioration = sum(etf_scores) / len(etf_scores)
# Count deteriorating ETFs (score >= 50)
deteriorating_count = sum(1 for s in etf_scores if s >= 50)
deteriorating_pct = deteriorating_count / len(etf_scores)
# Amplification: if 60%+ ETFs deteriorating, multiply by 1.3
if deteriorating_pct >= 0.60:
final_score = min(100, avg_deterioration * 1.3)
else:
final_score = avg_deterioration
final_score = round(min(100, max(0, final_score)))
# Signal
if final_score >= 70:
signal = "CRITICAL: Leadership broadly deteriorating"
elif final_score >= 50:
signal = "WARNING: Multiple leaders weakening"
elif final_score >= 30:
signal = "CAUTION: Some leaders showing strain"
else:
signal = "HEALTHY: Leadership intact"
return {
"score": final_score,
"signal": signal,
"avg_deterioration": round(avg_deterioration, 1),
"etfs_evaluated": len(etf_scores),
"etfs_deteriorating": deteriorating_count,
"deteriorating_pct": round(deteriorating_pct * 100, 1),
"amplified": deteriorating_pct >= 0.60,
"etf_details": etf_details,
"data_available": data_available,
"fetch_success_rate": round(fetch_success_rate, 2),
"partial_data_count": partial_data_count,
"basket_mode": basket_mode,
"basket": eval_list,
}
def _evaluate_etf(symbol: str, quote: dict, history: list[dict]) -> dict:
"""Evaluate a single ETF's deterioration level"""
score = 0
flags = []
price = quote.get("price", 0)
year_high = quote.get("yearHigh", 0)
quote.get("yearLow", 0)
# 1. Distance from 52-week high (0-40 points)
if year_high > 0:
distance_pct = (price - year_high) / year_high * 100
if distance_pct <= -25:
score += 40
flags.append(f">{abs(distance_pct):.0f}% below 52wk high (bear territory)")
elif distance_pct <= -15:
score += 30
flags.append(f"{abs(distance_pct):.0f}% below 52wk high (correction)")
elif distance_pct <= -10:
score += 20
flags.append(f"{abs(distance_pct):.0f}% below 52wk high")
elif distance_pct <= -5:
score += 10
flags.append(f"{abs(distance_pct):.0f}% below 52wk high")
else:
distance_pct = 0
# 2. Position vs moving averages (0-40 points)
if history and len(history) >= 50:
closes = [d.get("close", d.get("adjClose", 0)) for d in history]
# 50DMA
sma50 = sum(closes[:50]) / 50
if price < sma50:
score += 20
flags.append(f"Below 50DMA (${sma50:.2f})")
# 200DMA (if enough data)
if len(closes) >= 200:
sma200 = sum(closes[:200]) / 200
if price < sma200:
score += 20
flags.append(f"Below 200DMA (${sma200:.2f})")
elif len(closes) >= 50:
# Estimate 200DMA position from available data
if price < sma50 * 0.95: # >5% below 50DMA suggests below 200DMA
score += 10
flags.append("Likely below 200DMA (estimated)")
elif history and len(history) >= 20:
closes = [d.get("close", d.get("adjClose", 0)) for d in history]
sma20 = sum(closes[:20]) / 20
if price < sma20:
score += 15
flags.append(f"Below 20DMA (${sma20:.2f})")
# 3. Lower highs pattern (0-20 points)
if history and len(history) >= 20:
lower_highs = _detect_lower_highs(history)
if lower_highs:
score += 20
flags.append("Lower highs pattern detected")
score = min(100, score)
return {
"deterioration_score": score,
"price": price,
"year_high": year_high,
"distance_from_high_pct": round(distance_pct, 1),
"flags": flags,
}
def _detect_lower_highs(history: list[dict], lookback: int = 20) -> bool:
"""
Detect lower highs pattern in recent price action.
Look for at least 2 consecutive lower swing highs in the last 20 days.
"""
if len(history) < lookback:
return False
highs = [d.get("high", d.get("close", 0)) for d in history[:lookback]]
# Find local maxima (swing highs)
swing_highs = []
for i in range(1, len(highs) - 1):
if highs[i] > highs[i - 1] and highs[i] > highs[i + 1]:
swing_highs.append(highs[i])
# Need at least 2 swing highs to compare
if len(swing_highs) < 2:
return False
# Check if most recent swing highs are declining
# swing_highs[0] is earliest (since history is most-recent-first, reversed)
# Actually history[0] = most recent, so highs[0] = most recent high
# So swing_highs are in reverse chronological order
return swing_highs[0] < swing_highs[1]
#!/usr/bin/env python3
"""
Shared EMA/SMA calculation utilities for Market Top Detector.
All functions expect prices in most-recent-first order.
"""
def calc_ema(prices: list[float], period: int) -> float:
"""
Calculate Exponential Moving Average from prices (most recent first).
Args:
prices: List of prices, most recent first.
period: EMA period (must be >= 1).
Returns:
EMA value as float.
Raises:
ValueError: If prices is empty or period < 1.
"""
if not prices:
raise ValueError("prices must not be empty")
if period < 1:
raise ValueError("period must be >= 1")
if len(prices) < period:
return sum(prices) / len(prices)
prices_rev = prices[::-1]
sma = sum(prices_rev[:period]) / period
ema = sma
k = 2 / (period + 1)
for p in prices_rev[period:]:
ema = p * k + ema * (1 - k)
return ema
def calc_sma(prices: list[float], period: int) -> float:
"""
Calculate Simple Moving Average from prices (most recent first).
Args:
prices: List of prices, most recent first.
period: SMA period (must be >= 1).
Returns:
SMA value as float.
Raises:
ValueError: If prices is empty or period < 1.
"""
if not prices:
raise ValueError("prices must not be empty")
if period < 1:
raise ValueError("period must be >= 1")
if len(prices) < period:
return sum(prices) / len(prices)
return sum(prices[:period]) / period
#!/usr/bin/env python3
"""
Component 6: Sentiment & Speculation (Weight: 10%)
Data sources:
- VIX: FMP API quote
- Put/Call ratio: CLI argument (from WebSearch)
- VIX term structure: CLI argument (from WebSearch) or auto-detected
- Margin Debt YoY: CLI argument (from WebSearch)
Scoring (additive, max 100):
Put/Call < 0.60 -> +35pt (extreme call buying = complacency)
Put/Call < 0.70 -> +25pt
Put/Call < 0.80 -> +12pt
Put/Call >= 0.80 -> +0pt (healthy caution)
VIX < 12 -> +25pt (extreme low fear)
VIX < 14 -> +17pt
VIX < 16 -> +8pt
VIX 16-20 -> +0pt (normal)
VIX > 25 -> -8pt (fear already present, top less likely)
VIX term structure:
Steep contango (normal) -> +25pt (complacency)
Normal contango -> +12pt
Flat -> +0pt
Backwardation -> -8pt (hedging demand = fear)
Margin Debt YoY:
> 30% -> +15pt (extreme leverage)
> 20% -> +10pt
> 10% -> +5pt
<= 10% -> +0pt
"""
from typing import Optional
# VIX term structure states
STEEP_CONTANGO = "steep_contango"
NORMAL_CONTANGO = "contango"
FLAT = "flat"
BACKWARDATION = "backwardation"
def calculate_sentiment(
vix_level: Optional[float] = None,
put_call_ratio: Optional[float] = None,
vix_term_structure: Optional[str] = None,
margin_debt_yoy_pct: Optional[float] = None,
) -> dict:
"""
Calculate sentiment and speculation score.
Args:
vix_level: Current VIX value (from FMP API)
put_call_ratio: CBOE equity put/call ratio (from WebSearch/CLI)
vix_term_structure: One of 'steep_contango', 'contango', 'flat', 'backwardation'
margin_debt_yoy_pct: Year-over-year margin debt change % (optional extra context)
Returns:
Dict with score (0-100), signal, component details
"""
# All inputs missing -> neutral default
if vix_level is None and put_call_ratio is None and vix_term_structure is None:
return {
"score": 50,
"signal": "NO DATA: All sentiment inputs missing (neutral default)",
"data_available": False,
"total_points": 0,
"details": {},
}
total_points = 0
details = {}
# 1. Put/Call Ratio scoring (max 35pt)
pc_points = 0
if put_call_ratio is not None:
if put_call_ratio < 0.60:
pc_points = 35
elif put_call_ratio < 0.70:
pc_points = 25
elif put_call_ratio < 0.80:
pc_points = 12
else:
pc_points = 0
details["put_call_ratio"] = {
"value": put_call_ratio,
"points": pc_points,
"interpretation": _interpret_put_call(put_call_ratio),
}
else:
details["put_call_ratio"] = {"value": None, "points": 0, "interpretation": "No data"}
total_points += pc_points
# 2. VIX Level scoring (max 25pt)
vix_points = 0
if vix_level is not None:
if vix_level < 12:
vix_points = 25
elif vix_level < 14:
vix_points = 17
elif vix_level < 16:
vix_points = 8
elif vix_level <= 25:
vix_points = 0
else:
vix_points = -8 # High fear = top less likely
details["vix_level"] = {
"value": round(vix_level, 2),
"points": vix_points,
"interpretation": _interpret_vix(vix_level),
}
else:
details["vix_level"] = {"value": None, "points": 0, "interpretation": "No data"}
total_points += vix_points
# 3. VIX Term Structure scoring (max 25pt)
vts_points = 0
if vix_term_structure:
if vix_term_structure == STEEP_CONTANGO:
vts_points = 25
elif vix_term_structure == NORMAL_CONTANGO:
vts_points = 12
elif vix_term_structure == FLAT:
vts_points = 0
elif vix_term_structure == BACKWARDATION:
vts_points = -8
details["vix_term_structure"] = {
"value": vix_term_structure,
"points": vts_points,
"interpretation": _interpret_vix_term(vix_term_structure),
}
else:
details["vix_term_structure"] = {"value": None, "points": 0, "interpretation": "No data"}
total_points += vts_points
# 4. Margin Debt YoY scoring (max 15pt)
md_points = 0
if margin_debt_yoy_pct is not None:
if margin_debt_yoy_pct > 30:
md_points = 15
elif margin_debt_yoy_pct > 20:
md_points = 10
elif margin_debt_yoy_pct > 10:
md_points = 5
else:
md_points = 0
details["margin_debt"] = {
"yoy_pct": margin_debt_yoy_pct,
"points": md_points,
"interpretation": _interpret_margin_debt(margin_debt_yoy_pct),
}
total_points += md_points
# Clamp to 0-100
score = max(0, min(100, total_points))
# Signal
if score >= 70:
signal = "CRITICAL: Extreme complacency / speculation"
elif score >= 50:
signal = "WARNING: Elevated complacency"
elif score >= 30:
signal = "CAUTION: Some speculative excess"
elif score >= 10:
signal = "MIXED: Moderate sentiment"
else:
signal = "HEALTHY: No excessive speculation"
return {
"score": score,
"signal": signal,
"data_available": True,
"total_points": total_points,
"details": details,
}
def _interpret_put_call(ratio: float) -> str:
if ratio < 0.60:
return "Extreme call buying - maximum complacency"
elif ratio < 0.70:
return "Elevated call buying - notable optimism"
elif ratio < 0.80:
return "Slightly optimistic"
elif ratio < 0.90:
return "Normal range"
else:
return "Elevated put buying - caution/fear present"
def _interpret_vix(level: float) -> str:
if level < 12:
return "Extreme low fear - complacency zone"
elif level < 14:
return "Low fear"
elif level < 16:
return "Mildly low volatility"
elif level <= 20:
return "Normal range"
elif level <= 25:
return "Elevated fear"
else:
return "High fear - panic present"
def _interpret_vix_term(structure: str) -> str:
mapping = {
STEEP_CONTANGO: "Market expects calm - complacency in term structure",
NORMAL_CONTANGO: "Normal term structure",
FLAT: "Uncertainty - no clear expectation",
BACKWARDATION: "Hedging demand elevated - fear in term structure",
}
return mapping.get(structure, "Unknown")
def _interpret_margin_debt(yoy_pct: float) -> str:
if yoy_pct >= 30:
return f"DANGER: Margin debt surging +{yoy_pct:.0f}% YoY"
elif yoy_pct >= 20:
return f"WARNING: Rapid leverage increase +{yoy_pct:.0f}% YoY"
elif yoy_pct >= 10:
return f"Elevated: +{yoy_pct:.0f}% YoY"
elif yoy_pct >= 0:
return f"Normal: +{yoy_pct:.0f}% YoY"
else:
return f"Deleveraging: {yoy_pct:.0f}% YoY"
#!/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 Market Top Detector
Provides rate-limited access to Financial Modeling Prep API endpoints
for market top detection analysis.
Features:
- Rate limiting (0.3s between requests)
- Automatic retry on 429 errors
- Session caching for duplicate requests
- Batch quote support for ETF baskets
"""
import os
import sys
import time
from datetime import date, timedelta
from typing import Optional
try:
import requests
except ImportError:
print("ERROR: requests library not found. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
# --- FMP endpoint fallback: stable (new users) -> v3 (legacy users) ---
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 = date.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}
def _has_usable_history(data) -> bool:
"""True only when `data` is a dict carrying a non-empty `historical` list.
A dict with an empty `historical` list (e.g. an index/ETF unavailable on the
caller's FMP plan) is treated as unusable so the caller can fall back to
yfinance instead of caching an empty result.
"""
return bool(
isinstance(data, dict) and isinstance(data.get("historical"), list) and data["historical"]
)
class FMPClient:
"""Client for Financial Modeling Prep API with rate limiting and caching"""
BASE_URL = "https://financialmodelingprep.com/api/v3"
RATE_LIMIT_DELAY = 0.3 # 300ms between requests
_ENDPOINT_FAILURE_THRESHOLD = 3 # disable endpoint after N consecutive failures
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("FMP_API_KEY")
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 = {}
self.last_call_time = 0
self.rate_limit_reached = False
self.retry_count = 0
self.max_retries = 1
self.api_calls_made = 0
# Circuit breaker: track consecutive failures per endpoint URL prefix
self._endpoint_failures: dict[str, int] = {}
self._disabled_endpoints: set[str] = set()
def _rate_limited_get(
self, url: str, params: Optional[dict] = None, quiet: bool = False
) -> Optional[dict]:
if self.rate_limit_reached:
return None
if params is None:
params = {}
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()
self.api_calls_made += 1
if response.status_code == 200:
self.retry_count = 0
return response.json()
elif response.status_code == 429:
self.retry_count += 1
if self.retry_count <= 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.", file=sys.stderr)
self.rate_limit_reached = True
return None
else:
if not quiet:
print(
f"ERROR: API request failed: {response.status_code} - {response.text[:200]}",
file=sys.stderr,
)
return None
except requests.exceptions.RequestException as e:
print(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 for legacy users.
Returns parsed JSON in v3-compatible shape, or None if all fail.
Non-last endpoints use quiet=True to suppress expected 403 stderr.
"""
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):
# Circuit breaker: skip endpoints with too many consecutive failures
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: # falsy (None, [], {}) — try next endpoint
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
# Shape validation: reject truthy-but-wrong-shape responses
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:
# stable batch format -> v3 single format (exact match only)
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:
"""Track consecutive failures and disable endpoint after threshold."""
failures = self._endpoint_failures.get(base_url, 0) + 1
self._endpoint_failures[base_url] = failures
if failures >= self._ENDPOINT_FAILURE_THRESHOLD:
self._disabled_endpoints.add(base_url)
def get_quote(self, symbols: str) -> Optional[list[dict]]:
"""Fetch real-time quote data for one or more symbols (comma-separated).
Falls back to a yfinance-derived quote when FMP returns nothing (e.g. an
index like ^GSPC/^VIX gated behind a paid FMP plan). Fallback is
single-symbol only — comma lists are left to FMP.
"""
cache_key = f"quote_{symbols}"
if cache_key in self.cache:
return self.cache[cache_key]
data = self._request_with_fallback("quote", symbols)
if not data and "," not in symbols:
data = self._get_quote_from_yfinance(symbols)
if data:
self.cache[cache_key] = data
return data
def _get_quote_from_yfinance(self, symbol: str) -> Optional[list[dict]]:
"""Fallback quote derived from recent yfinance daily bars.
Returns the FMP quote shape (a list with one dict carrying
price/previousClose/change/changesPercentage/open/high/low/volume/
yearHigh/yearLow), or ``None`` when history is unavailable. Year
high/low are approximated from ~252 daily bars.
"""
hist = self._get_hist_from_yfinance(symbol, 260)
if not _has_usable_history(hist):
return None
bars = hist["historical"] # most-recent-first
latest = bars[0]
prev_close = bars[1]["close"] if len(bars) > 1 else latest["close"]
price = latest["close"]
change = price - prev_close
pct = (change / prev_close * 100) if prev_close else 0.0
window = bars[:252]
year_high = max(b["high"] for b in window)
year_low = min(b["low"] for b in window)
return [
{
"symbol": symbol,
"price": price,
"previousClose": prev_close,
"change": round(change, 4),
"changesPercentage": round(pct, 4),
"open": latest["open"],
"dayHigh": latest["high"],
"dayLow": latest["low"],
"high": latest["high"],
"low": latest["low"],
"volume": latest["volume"],
"yearHigh": year_high,
"yearLow": year_low,
}
]
def get_historical_prices(self, symbol: str, days: int = 365) -> Optional[dict]:
"""Fetch historical daily OHLCV data.
Falls back to yfinance when the FMP endpoint returns no usable history
(e.g. an index/ETF gated behind a paid FMP plan). The yfinance path
needs no extra API key; an FMP key is still required to build the client.
"""
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 not _has_usable_history(data):
data = self._get_hist_from_yfinance(symbol, days)
if _has_usable_history(data):
self.cache[cache_key] = data
return data
return None
def _get_hist_from_yfinance(self, symbol: str, days: int) -> Optional[dict]:
"""Fallback: fetch daily history via yfinance when FMP is unavailable.
Returns the FMP-compatible shape ``{"symbol": ..., "historical": [...]}``
with most-recent-first bars (date/open/high/low/close/adjClose/volume),
or ``None`` on empty/error. ``yfinance`` is imported lazily so the FMP
success path never depends on it.
"""
try:
import yfinance as yf
end = date.today()
start = end - timedelta(days=int(days * 1.5)) # cover weekends/holidays
df = yf.download(
symbol,
start=start.isoformat(),
end=end.isoformat(),
auto_adjust=True,
progress=False,
)
if df is None or df.empty:
return None
# yfinance returns MultiIndex columns for a single ticker.
if hasattr(df.columns, "levels"):
df.columns = df.columns.droplevel(1)
historical = []
for idx, row in df.iterrows():
close = float(row["Close"])
historical.append(
{
"date": idx.strftime("%Y-%m-%d"),
"open": float(row["Open"]),
"high": float(row["High"]),
"low": float(row["Low"]),
"close": close,
"adjClose": close, # auto_adjust=True -> Close already adjusted
"volume": int(row["Volume"]),
}
)
if not historical:
return None
historical.reverse() # yfinance ascending -> FMP most-recent-first
return {"symbol": symbol, "historical": historical[:days]}
except Exception as e:
print(f"WARNING: yfinance history fallback failed for {symbol}: {e}", file=sys.stderr)
return None
def get_batch_quotes(self, symbols: list[str]) -> dict[str, dict]:
"""Fetch quotes for a list of symbols.
Note: The FMP `/stable/quote` endpoint only accepts a single symbol per
call. Passing comma-separated symbols silently returns []. The
deprecated `/api/v3/quote/A,B,C` batch syntax now returns 403 for
non-legacy keys (retired Aug 31, 2025). So we iterate one symbol at a
time. Rate limiting in `_rate_limited_get` keeps this within plan
limits.
"""
results = {}
for symbol in symbols:
quotes = self.get_quote(symbol)
if quotes:
for q in quotes:
if q.get("symbol"):
results[q["symbol"]] = q
return results
def get_batch_historical(self, symbols: list[str], days: int = 50) -> dict[str, list[dict]]:
"""Fetch historical prices for multiple symbols"""
results = {}
for symbol in symbols:
data = self.get_historical_prices(symbol, days=days)
if data and "historical" in data:
results[symbol] = data["historical"]
return results
def calculate_ema(self, prices: list[float], period: int) -> float:
"""Calculate EMA (thin wrapper around math_utils for backward compat)."""
from calculators.math_utils import calc_ema
return calc_ema(prices, period)
def calculate_sma(self, prices: list[float], period: int) -> float:
"""Calculate SMA (thin wrapper around math_utils for backward compat)."""
from calculators.math_utils import calc_sma
return calc_sma(prices, period)
def get_vix_term_structure(self) -> Optional[dict]:
"""
Auto-detect VIX term structure by comparing VIX to VIX3M.
Returns:
Dict with ratio, classification, or None if VIX3M unavailable.
"""
vix_quotes = self.get_quote("^VIX")
vix3m_quotes = self.get_quote("^VIX3M")
if not vix_quotes or not vix3m_quotes:
return None
vix_price = vix_quotes[0].get("price", 0)
vix3m_price = vix3m_quotes[0].get("price", 0)
if vix3m_price <= 0:
return None
ratio = vix_price / vix3m_price
if ratio < 0.85:
classification = "steep_contango"
elif ratio < 0.95:
classification = "contango"
elif ratio <= 1.05:
classification = "flat"
else:
classification = "backwardation"
return {
"vix": round(vix_price, 2),
"vix3m": round(vix3m_price, 2),
"ratio": round(ratio, 3),
"classification": classification,
}
def get_api_stats(self) -> dict:
return {
"cache_entries": len(self.cache),
"api_calls_made": self.api_calls_made,
"rate_limit_reached": self.rate_limit_reached,
}
#!/usr/bin/env python3
"""
Historical Top Pattern Comparator
Compares current component scores to estimated score patterns from
historical market tops (2000, 2007, 2018, 2022) using Sum of Squared
Differences (SSD) to find the closest match.
"""
# Component weights (must match scorer.py)
COMPONENT_WEIGHTS = {
"distribution_days": 0.25,
"leading_stocks": 0.20,
"defensive_rotation": 0.15,
"breadth_divergence": 0.15,
"index_technical": 0.15,
"sentiment": 0.10,
}
# Estimated median component scores during historical tops
# Based on references/historical_tops.md analysis
HISTORICAL_TOPS = {
"2000 (Dot-Com Bubble)": {
"distribution_days": 90,
"leading_stocks": 85,
"defensive_rotation": 70,
"breadth_divergence": 95,
"index_technical": 60,
"sentiment": 90,
},
"2007 (Financial Crisis)": {
"distribution_days": 80,
"leading_stocks": 75,
"defensive_rotation": 85,
"breadth_divergence": 80,
"index_technical": 70,
"sentiment": 75,
},
"2018 (Q4 Correction)": {
"distribution_days": 60,
"leading_stocks": 50,
"defensive_rotation": 40,
"breadth_divergence": 55,
"index_technical": 65,
"sentiment": 55,
},
"2022 (Rate Hike Bear)": {
"distribution_days": 75,
"leading_stocks": 90,
"defensive_rotation": 60,
"breadth_divergence": 70,
"index_technical": 80,
"sentiment": 65,
},
}
COMPONENT_KEYS = [
"distribution_days",
"leading_stocks",
"defensive_rotation",
"breadth_divergence",
"index_technical",
"sentiment",
]
def _compute_ssd(current: dict[str, float], historical: dict[str, float]) -> float:
"""Compute weighted sum of squared differences between two score dicts.
Each component's squared difference is multiplied by its weight,
aligning the distance metric with the composite score design.
"""
ssd = 0.0
for key in COMPONENT_KEYS:
diff = current.get(key, 0) - historical.get(key, 0)
weight = COMPONENT_WEIGHTS.get(key, 1.0 / len(COMPONENT_KEYS))
ssd += weight * diff * diff
return ssd
def compare_to_historical(current_scores: dict[str, float]) -> dict:
"""
Compare current scores to historical top patterns.
Args:
current_scores: Dict of component key -> score (0-100)
Returns:
Dict with closest_match, all_comparisons, narrative
"""
comparisons = []
for name, hist_scores in HISTORICAL_TOPS.items():
ssd = _compute_ssd(current_scores, hist_scores)
comparisons.append(
{
"name": name,
"ssd": round(ssd, 1),
"scores": hist_scores,
}
)
comparisons.sort(key=lambda x: x["ssd"])
closest = comparisons[0]
narrative = _generate_narrative(current_scores, closest)
return {
"closest_match": closest["name"],
"closest_ssd": closest["ssd"],
"comparisons": comparisons,
"narrative": narrative,
}
def _generate_narrative(current_scores: dict[str, float], closest: dict) -> str:
"""Generate a textual comparison highlighting key differences."""
hist_scores = closest["scores"]
name = closest["name"]
diffs = []
for key in COMPONENT_KEYS:
curr = current_scores.get(key, 0)
hist = hist_scores.get(key, 0)
diff = curr - hist
if abs(diff) >= 15:
direction = "higher" if diff > 0 else "lower"
label = key.replace("_", " ").title()
diffs.append(f"{label} is {abs(diff):.0f}pt {direction} than {name}")
if not diffs:
return f"Current pattern closely matches {name} across all components."
diff_text = "; ".join(diffs)
return f"Closest to {name}. Key differences: {diff_text}."
#!/usr/bin/env python3
"""
Market Top Detector - Main Orchestrator
Integrates O'Neil (Distribution Days), Minervini (Leading Stock Deterioration),
and Monty (Defensive Sector Rotation) approaches to detect market top probability.
Usage:
# With FMP API (recommended):
python3 market_top_detector.py --api-key YOUR_KEY \\
--breadth-200dma 62.26 --breadth-50dma 55.0 \\
--put-call 0.67 --vix-term contango
# Using environment variable:
export FMP_API_KEY=YOUR_KEY
python3 market_top_detector.py --breadth-200dma 62.26
# Minimal (VIX from API, rest from CLI):
python3 market_top_detector.py --api-key YOUR_KEY \\
--breadth-200dma 62.26 --put-call 0.67
Output:
- JSON: market_top_YYYY-MM-DD_HHMMSS.json
- Markdown: market_top_YYYY-MM-DD_HHMMSS.md
"""
import argparse
import glob
import json
import os
import sys
from datetime import datetime
from typing import Optional
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(__file__))
from breadth_csv_client import fetch_breadth_200dma
from calculators.breadth_calculator import calculate_breadth_divergence
from calculators.defensive_rotation_calculator import (
DEFENSIVE_ETFS,
OFFENSIVE_ETFS,
calculate_defensive_rotation,
)
from calculators.distribution_day_calculator import calculate_distribution_days
from calculators.index_technical_calculator import calculate_index_technical
from calculators.leading_stock_calculator import (
CANDIDATE_POOL,
LEADING_ETFS,
calculate_leading_stock_health,
select_dynamic_basket,
)
from calculators.sentiment_calculator import calculate_sentiment
from fmp_client import FMPClient
from historical_comparator import compare_to_historical
from report_generator import generate_json_report, generate_markdown_report
from scenario_engine import generate_scenarios
from scorer import calculate_composite_score, detect_follow_through_day
def parse_arguments():
parser = argparse.ArgumentParser(
description="Market Top Detector - O'Neil/Minervini/Monty Integration"
)
# API key
parser.add_argument(
"--api-key", help="FMP API key (defaults to FMP_API_KEY environment variable)"
)
# WebSearch-sourced data (provided by Claude before script execution)
parser.add_argument(
"--breadth-200dma",
type=float,
default=None,
help="Percent of S&P 500 stocks above 200DMA (e.g., 62.26)",
)
parser.add_argument(
"--breadth-50dma",
type=float,
default=None,
help="Percent of S&P 500 stocks above 50DMA (e.g., 55.0)",
)
parser.add_argument(
"--put-call", type=float, default=None, help="CBOE equity put/call ratio (e.g., 0.67)"
)
parser.add_argument(
"--vix-term",
choices=["steep_contango", "contango", "flat", "backwardation"],
default=None,
help="VIX term structure state",
)
parser.add_argument(
"--margin-debt-yoy",
type=float,
default=None,
help="Margin debt year-over-year change percent (e.g., 36.0)",
)
# Data freshness dates
parser.add_argument(
"--breadth-200dma-date", default=None, help="Date of breadth 200DMA data (YYYY-MM-DD)"
)
parser.add_argument(
"--breadth-50dma-date", default=None, help="Date of breadth 50DMA data (YYYY-MM-DD)"
)
parser.add_argument(
"--put-call-date", default=None, help="Date of put/call ratio data (YYYY-MM-DD)"
)
parser.add_argument(
"--margin-debt-date", default=None, help="Date of margin debt data (YYYY-MM-DD)"
)
# Additional context (not scored, but included in report)
parser.add_argument(
"--context",
nargs="*",
default=[],
help="Additional context items in 'key=value' format (e.g., 'Consumer Confidence=57.3')",
)
# Breadth auto-fetch control
parser.add_argument(
"--no-auto-breadth",
action="store_true",
help="Disable auto-fetch of 200DMA breadth from TraderMonty CSV",
)
# Leading stock basket mode
parser.add_argument(
"--static-basket",
action="store_true",
help="Use static default ETF basket instead of dynamic selection",
)
# Output
parser.add_argument("--output-dir", default="reports/", help="Output directory for reports")
return parser.parse_args()
def compute_data_freshness(date_args: dict) -> dict:
"""
Compute data freshness factors for CLI input dates.
Uses business days instead of calendar days so that Friday data
remains fresh on Monday. Future dates are treated as anomalous input.
Args:
date_args: Dict with keys like 'breadth_200dma_date', 'breadth_50dma_date',
'put_call_date', 'margin_debt_date' -> YYYY-MM-DD strings.
Returns:
Dict with per-input freshness info and overall_confidence (min of all factors).
"""
from datetime import date as dateclass
from utils import count_business_days
freshness_map = {
"breadth_200dma_date": "breadth_200dma",
"breadth_50dma_date": "breadth_50dma",
"put_call_date": "put_call",
"margin_debt_date": "margin_debt",
}
result = {}
factors = []
today = dateclass.today()
for arg_key, label in freshness_map.items():
date_str = date_args.get(arg_key)
if not date_str:
continue
try:
d = dateclass.fromisoformat(date_str)
except (ValueError, TypeError):
result[label] = {"date": date_str, "age_days": None, "factor": 0.70}
factors.append(0.70)
continue
# Reject future dates as input anomaly
if d > today:
result[label] = {"date": date_str, "age_days": None, "factor": 0.70}
factors.append(0.70)
continue
biz_days = count_business_days(d, today)
if biz_days <= 1:
factor = 1.0
elif biz_days <= 3:
factor = 0.95
elif biz_days <= 7:
factor = 0.85
else:
factor = 0.70
result[label] = {"date": date_str, "age_days": biz_days, "factor": factor}
factors.append(factor)
result["overall_confidence"] = min(factors) if factors else 1.0
return result
def _load_previous_report(output_dir: str) -> Optional[dict]:
"""
Load the most recent market_top_*.json from output_dir.
Files are named market_top_YYYY-MM-DD_HHMMSS.json, so lexicographic
sorting gives chronological order.
"""
pattern = os.path.join(output_dir, "market_top_*.json")
files = sorted(glob.glob(pattern))
if not files:
return None
try:
with open(files[-1]) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def _compute_deltas(current_scores: dict[str, float], previous_report: Optional[dict]) -> dict:
"""
Compute delta between current and previous component scores.
Returns:
Dict with per-component delta info and composite delta.
If no previous report, direction is 'first_run' for all.
"""
component_keys = [
"distribution_days",
"leading_stocks",
"defensive_rotation",
"breadth_divergence",
"index_technical",
"sentiment",
]
deltas = {}
if previous_report is None:
for key in component_keys:
deltas[key] = {"delta": 0, "direction": "first_run"}
return {
"components": deltas,
"composite_delta": 0,
"composite_direction": "first_run",
"previous_date": None,
}
prev_components = previous_report.get("components", {})
prev_composite = previous_report.get("composite", {}).get("composite_score", 0)
for key in component_keys:
prev_score = prev_components.get(key, {}).get("score", 0)
curr_score = current_scores.get(key, 0)
delta = curr_score - prev_score
if abs(delta) <= 3:
direction = "stable"
elif delta > 0:
direction = "worsening"
else:
direction = "improving"
deltas[key] = {"delta": round(delta, 1), "direction": direction, "previous": prev_score}
prev_date = previous_report.get("metadata", {}).get("generated_at", None)
return {
"components": deltas,
"composite_delta": 0, # Will be filled after composite calc
"composite_direction": "first_run",
"previous_date": prev_date,
"previous_composite": prev_composite,
}
def main():
args = parse_arguments()
print("=" * 70)
print("Market Top Detector")
print("O'Neil (Distribution) + Minervini (Leadership) + Monty (Rotation)")
print("=" * 70)
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)
# ========================================================================
# Step 1: Fetch shared data (indices, ETFs)
# ========================================================================
print()
print("Step 1: Fetching Market Data")
print("-" * 70)
# S&P 500 data
print(" Fetching S&P 500 data...", end=" ", flush=True)
sp500_quote_list = client.get_quote("^GSPC")
sp500_quote = sp500_quote_list[0] if sp500_quote_list else None
sp500_history_data = client.get_historical_prices("^GSPC", days=260)
sp500_history = sp500_history_data.get("historical", []) if sp500_history_data else []
if sp500_quote and sp500_history:
print(f"OK (${sp500_quote.get('price', 0):.2f}, {len(sp500_history)} days)")
else:
print("FAILED")
print("ERROR: Cannot proceed without S&P 500 data", file=sys.stderr)
sys.exit(1)
# NASDAQ/QQQ data
print(" Fetching NASDAQ (QQQ) data...", end=" ", flush=True)
qqq_quote_list = client.get_quote("QQQ")
qqq_quote = qqq_quote_list[0] if qqq_quote_list else None
qqq_history_data = client.get_historical_prices("QQQ", days=260)
qqq_history = qqq_history_data.get("historical", []) if qqq_history_data else []
if qqq_quote and qqq_history:
print(f"OK (${qqq_quote.get('price', 0):.2f}, {len(qqq_history)} days)")
else:
print("WARN - NASDAQ data unavailable, using S&P 500 only")
# VIX
print(" Fetching VIX...", end=" ", flush=True)
vix_quote_list = client.get_quote("^VIX")
vix_quote = vix_quote_list[0] if vix_quote_list else None
vix_level = vix_quote.get("price", None) if vix_quote else None
if vix_level:
print(f"OK ({vix_level:.2f})")
else:
print("WARN - VIX unavailable")
# VIX Term Structure auto-detection
effective_vix_term = args.vix_term # CLI override takes priority
vix_term_auto = None
if effective_vix_term is None:
print(" Auto-detecting VIX term structure...", end=" ", flush=True)
vix_term_auto = client.get_vix_term_structure()
if vix_term_auto:
effective_vix_term = vix_term_auto["classification"]
print(f"OK ({effective_vix_term}, ratio={vix_term_auto['ratio']})")
else:
print("WARN - VIX3M unavailable, manual --vix-term needed")
# Leading ETFs (dynamic or static basket)
if args.static_basket:
selected_basket = list(LEADING_ETFS)
print(" Fetching Leading ETFs (static basket)...", end=" ", flush=True)
leading_quotes = client.get_batch_quotes(selected_basket)
leading_historical = client.get_batch_historical(selected_basket, days=60)
else:
print(" Fetching candidate pool quotes for dynamic basket...", end=" ", flush=True)
candidate_quotes = client.get_batch_quotes(CANDIDATE_POOL)
print(f"OK ({len(candidate_quotes)} candidates)")
selected_basket = select_dynamic_basket(candidate_quotes)
print(f" Selected dynamic basket: {selected_basket}")
print(" Fetching Leading ETFs (dynamic basket)...", end=" ", flush=True)
leading_quotes = {s: candidate_quotes[s] for s in selected_basket if s in candidate_quotes}
leading_historical = client.get_batch_historical(selected_basket, days=60)
print(f"OK ({len(leading_quotes)} quotes, {len(leading_historical)} histories)")
# Sector ETFs
all_sector_etfs = list(set(DEFENSIVE_ETFS + OFFENSIVE_ETFS))
# Remove QQQ from sector fetching if already fetched
sector_etfs_to_fetch = [e for e in all_sector_etfs if e != "QQQ"]
print(" Fetching Sector ETFs...", end=" ", flush=True)
sector_historical = client.get_batch_historical(sector_etfs_to_fetch, days=50)
# Add QQQ history if available
if qqq_history:
sector_historical["QQQ"] = qqq_history[:50]
print(f"OK ({len(sector_historical)} ETFs)")
print()
# ========================================================================
# Step 2: Calculate Components
# ========================================================================
print("Step 2: Calculating Components")
print("-" * 70)
# Component 1: Distribution Days (25%)
print(" [1/6] Distribution Day Count...", end=" ", flush=True)
comp1 = calculate_distribution_days(sp500_history, qqq_history)
print(f"Score: {comp1['score']} ({comp1['signal']})")
# Component 2: Leading Stock Health (20%)
print(" [2/6] Leading Stock Health...", end=" ", flush=True)
comp2 = calculate_leading_stock_health(
leading_quotes, leading_historical, etf_list=selected_basket
)
print(f"Score: {comp2['score']} ({comp2['signal']})")
# Component 3: Defensive Rotation (15%)
print(" [3/6] Defensive Sector Rotation...", end=" ", flush=True)
comp3 = calculate_defensive_rotation(sector_historical)
print(f"Score: {comp3['score']} ({comp3['signal']})")
# Auto-fetch 200DMA breadth if not provided via CLI
effective_breadth_200dma = args.breadth_200dma
breadth_source = "cli"
breadth_auto_date = None
if effective_breadth_200dma is None and not args.no_auto_breadth:
print(" Fetching 200DMA breadth from TraderMonty CSV...", end=" ", flush=True)
auto_result = fetch_breadth_200dma()
if auto_result is not None:
effective_breadth_200dma = auto_result["value"]
breadth_source = "auto"
breadth_auto_date = auto_result["date"]
fresh_str = (
"fresh" if auto_result["is_fresh"] else f"STALE ({auto_result['days_old']}d old)"
)
print(f"OK ({effective_breadth_200dma}%, {auto_result['date']}, {fresh_str})")
if not auto_result["is_fresh"]:
print(f" WARNING: Breadth data is {auto_result['days_old']} days old")
else:
print("FAILED (will use neutral default)")
# Component 4: Breadth Divergence (15%)
print(" [4/6] Market Breadth Divergence...", end=" ", flush=True)
# Calculate index distance from 52-week high
sp500_year_high = sp500_quote.get("yearHigh", 0)
sp500_price = sp500_quote.get("price", 0)
if sp500_year_high > 0:
index_dist = (sp500_price - sp500_year_high) / sp500_year_high * 100
else:
index_dist = 0
comp4 = calculate_breadth_divergence(
breadth_200dma=effective_breadth_200dma,
breadth_50dma=args.breadth_50dma,
index_distance_from_high_pct=index_dist,
)
comp4["breadth_source"] = breadth_source
if breadth_auto_date:
comp4["breadth_auto_date"] = breadth_auto_date
print(f"Score: {comp4['score']} ({comp4['signal']})")
# Component 5: Index Technical (15%)
print(" [5/6] Index Technical Condition...", end=" ", flush=True)
comp5 = calculate_index_technical(
sp500_history, qqq_history, sp500_quote=sp500_quote, nasdaq_quote=qqq_quote
)
print(f"Score: {comp5['score']} ({comp5['signal']})")
# Component 6: Sentiment (10%)
print(" [6/6] Sentiment & Speculation...", end=" ", flush=True)
comp6 = calculate_sentiment(
vix_level=vix_level,
put_call_ratio=args.put_call,
vix_term_structure=effective_vix_term,
margin_debt_yoy_pct=args.margin_debt_yoy,
)
print(f"Score: {comp6['score']} ({comp6['signal']})")
print()
# Compute data freshness
freshness_args = {
"breadth_200dma_date": breadth_auto_date
if breadth_source == "auto"
else args.breadth_200dma_date,
"breadth_50dma_date": args.breadth_50dma_date,
"put_call_date": args.put_call_date,
"margin_debt_date": args.margin_debt_date,
}
data_freshness = compute_data_freshness(freshness_args)
# ========================================================================
# Step 3: Composite Score
# ========================================================================
print("Step 3: Calculating Composite Score")
print("-" * 70)
component_scores = {
"distribution_days": comp1["score"],
"leading_stocks": comp2["score"],
"defensive_rotation": comp3["score"],
"breadth_divergence": comp4["score"],
"index_technical": comp5["score"],
"sentiment": comp6["score"],
}
data_availability = {
"distribution_days": True, # Always available (requires S&P data to run)
"leading_stocks": comp2.get("data_available", True),
"defensive_rotation": comp3.get("data_available", True),
"breadth_divergence": comp4.get("data_available", True),
"index_technical": comp5.get("data_available", True),
"sentiment": comp6.get("data_available", True),
}
composite = calculate_composite_score(component_scores, data_availability)
print(f" Composite Score: {composite['composite_score']}/100")
print(f" Risk Zone: {composite['zone']}")
print(f" Risk Budget: {composite['risk_budget']}")
print(
f" Strongest Warning: {composite['strongest_warning']['label']} "
f"({composite['strongest_warning']['score']})"
)
# Delta tracking vs previous run
previous_report = _load_previous_report(args.output_dir)
delta_info = _compute_deltas(component_scores, previous_report)
# Update composite delta now that we have the composite score
if previous_report is not None:
prev_composite = delta_info.get("previous_composite", 0)
comp_delta = composite["composite_score"] - prev_composite
delta_info["composite_delta"] = round(comp_delta, 1)
if abs(comp_delta) <= 3:
delta_info["composite_direction"] = "stable"
elif comp_delta > 0:
delta_info["composite_direction"] = "worsening"
else:
delta_info["composite_direction"] = "improving"
print(
f" vs Previous: {prev_composite} -> {composite['composite_score']} "
f"({delta_info['composite_delta']:+.1f})"
)
else:
print(" (First run - no comparison available)")
print()
# ========================================================================
# Step 4: Follow-Through Day Check
# ========================================================================
ftd = detect_follow_through_day(sp500_history, composite["composite_score"])
if ftd.get("applicable"):
print("Step 4: Follow-Through Day Monitor")
print("-" * 70)
print(f" {ftd['reason']}")
print()
# ========================================================================
# Step 5: Historical Comparison & Scenarios
# ========================================================================
print("Step 5: Historical Comparison & Scenarios")
print("-" * 70)
historical_comparison = compare_to_historical(component_scores)
print(f" Closest historical pattern: {historical_comparison['closest_match']}")
scenarios = generate_scenarios(component_scores, data_availability)
print(f" Generated {len(scenarios)} what-if scenarios")
print()
# ========================================================================
# Step 6: Generate Reports
# ========================================================================
print("Step 6: Generating Reports")
print("-" * 70)
# Parse additional context
additional_context = {}
for item in args.context:
if "=" in item:
key, value = item.split("=", 1)
additional_context[key.strip()] = value.strip()
# Build full analysis
analysis = {
"metadata": {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"data_mode": "FMP API + CLI inputs",
"api_calls": client.get_api_stats(),
"cli_inputs": {
"breadth_200dma": effective_breadth_200dma,
"breadth_200dma_source": breadth_source,
"breadth_200dma_auto_date": breadth_auto_date,
"breadth_50dma": args.breadth_50dma,
"put_call_ratio": args.put_call,
"vix_term_structure": args.vix_term,
"margin_debt_yoy_pct": args.margin_debt_yoy,
},
"vix_term_auto": vix_term_auto,
"index_data": {
"sp500_price": sp500_price,
"sp500_year_high": sp500_year_high,
"sp500_distance_from_high_pct": round(index_dist, 2),
"qqq_price": qqq_quote.get("price", 0) if qqq_quote else None,
"vix_level": vix_level,
},
"data_freshness": data_freshness,
},
"composite": composite,
"components": {
"distribution_days": comp1,
"leading_stocks": comp2,
"defensive_rotation": comp3,
"breadth_divergence": comp4,
"index_technical": comp5,
"sentiment": comp6,
},
"follow_through_day": ftd,
"historical_comparison": historical_comparison,
"scenarios": scenarios,
"delta": delta_info,
"additional_context": additional_context,
}
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_file = os.path.join(args.output_dir, f"market_top_{timestamp}.json")
md_file = os.path.join(args.output_dir, f"market_top_{timestamp}.md")
generate_json_report(analysis, json_file)
generate_markdown_report(analysis, md_file)
print()
print("=" * 70)
print("Market Top Detection Complete")
print("=" * 70)
print(f" Composite Score: {composite['composite_score']}/100")
print(f" Risk Zone: {composite['zone']}")
print(f" Risk Budget: {composite['risk_budget']}")
print(f" JSON Report: {json_file}")
print(f" Markdown Report: {md_file}")
print()
stats = client.get_api_stats()
print("API Usage:")
print(f" API calls made: {stats['api_calls_made']}")
print(f" Cache entries: {stats['cache_entries']}")
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
What-If Scenario Analysis Engine
Generates 4 sensitivity scenarios by perturbing component scores
and re-running the composite scoring engine.
"""
from scorer import calculate_composite_score
def generate_scenarios(
component_scores: dict[str, float], data_availability: dict[str, bool] = None
) -> list[dict]:
"""
Generate 4 what-if scenarios based on current component scores.
Scenarios:
1. Breadth Deterioration: breadth_divergence -> 55
2. Distribution Reset: distribution_days -> 55
3. Full Deterioration: top 3 weakest components each +20pt
4. Recovery: top 2 strongest components each -30pt
Args:
component_scores: Dict of component key -> score (0-100)
data_availability: Optional data availability dict
Returns:
List of 4 scenario dicts with name, changes, new_score, new_zone
"""
scenarios = []
# Scenario 1: Breadth Deterioration
s1 = dict(component_scores)
s1["breadth_divergence"] = max(s1.get("breadth_divergence", 0), 55)
s1_result = calculate_composite_score(s1, data_availability)
scenarios.append(
{
"name": "Breadth Deterioration",
"description": "Breadth divergence worsens to 55+",
"changes": {"breadth_divergence": s1["breadth_divergence"]},
"new_score": s1_result["composite_score"],
"new_zone": s1_result["zone"],
"delta": round(
s1_result["composite_score"] - _current_score(component_scores, data_availability),
1,
),
}
)
# Scenario 2: Distribution Reset (ease to 55 or less)
s2 = dict(component_scores)
s2["distribution_days"] = min(s2.get("distribution_days", 0), 55)
s2_result = calculate_composite_score(s2, data_availability)
scenarios.append(
{
"name": "Distribution Reset",
"description": "Distribution days ease to 55 or less",
"changes": {"distribution_days": s2["distribution_days"]},
"new_score": s2_result["composite_score"],
"new_zone": s2_result["zone"],
"delta": round(
s2_result["composite_score"] - _current_score(component_scores, data_availability),
1,
),
}
)
# Scenario 3: Full Deterioration - raise lowest 3 by +20pt
sorted_components = sorted(component_scores.items(), key=lambda x: x[1])
s3 = dict(component_scores)
changes_3 = {}
for key, val in sorted_components[:3]:
new_val = min(100, val + 20)
s3[key] = new_val
changes_3[key] = new_val
s3_result = calculate_composite_score(s3, data_availability)
scenarios.append(
{
"name": "Full Deterioration",
"description": "3 weakest components each worsen by +20pt",
"changes": changes_3,
"new_score": s3_result["composite_score"],
"new_zone": s3_result["zone"],
"delta": round(
s3_result["composite_score"] - _current_score(component_scores, data_availability),
1,
),
}
)
# Scenario 4: Recovery - lower highest 2 by -30pt
sorted_desc = sorted(component_scores.items(), key=lambda x: x[1], reverse=True)
s4 = dict(component_scores)
changes_4 = {}
for key, val in sorted_desc[:2]:
new_val = max(0, val - 30)
s4[key] = new_val
changes_4[key] = new_val
s4_result = calculate_composite_score(s4, data_availability)
scenarios.append(
{
"name": "Recovery",
"description": "2 strongest warning signals improve by -30pt",
"changes": changes_4,
"new_score": s4_result["composite_score"],
"new_zone": s4_result["zone"],
"delta": round(
s4_result["composite_score"] - _current_score(component_scores, data_availability),
1,
),
}
)
return scenarios
def _current_score(
component_scores: dict[str, float], data_availability: dict[str, bool] = None
) -> float:
"""Helper to get the current composite score."""
result = calculate_composite_score(component_scores, data_availability)
return result["composite_score"]
"""Shared fixtures for Market Top Detector tests"""
import os
import sys
# Add scripts directory to path so calculators can be imported
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Add tests directory to path so helpers can be imported
sys.path.insert(0, os.path.dirname(__file__))
"""Importable test helpers for Market Top Detector tests"""
def make_daily_bar(close, volume=1000000, date="2026-01-15", open_=None, high=None, low=None):
"""Helper to create a daily OHLCV bar dict."""
if open_ is None:
open_ = close
if high is None:
high = close * 1.005
if low is None:
low = close * 0.995
return {
"date": date,
"open": open_,
"high": high,
"low": low,
"close": close,
"adjClose": close,
"volume": volume,
}
def make_history(closes, base_volume=1000000, start_date="2026-01-15"):
"""Create a list of daily bars from a list of closes (most recent first)."""
bars = []
for i, close in enumerate(closes):
vol = base_volume + (i * 1000) # Slight volume variation
bars.append(
make_daily_bar(
close=close,
volume=vol,
date=f"day-{i}",
)
)
return bars
def make_history_with_volumes(close_volume_pairs, start_date="2026-01-15"):
"""Create bars from list of (close, volume) tuples (most recent first)."""
bars = []
for i, (close, volume) in enumerate(close_volume_pairs):
bars.append(
make_daily_bar(
close=close,
volume=volume,
date=f"day-{i}",
)
)
return bars
"""Tests for Breadth Calculator"""
from calculators.breadth_calculator import (
_score_200dma_breadth,
calculate_breadth_divergence,
)
class TestScore200dmaBreadth:
"""Boundary tests for 200DMA scoring."""
def test_critical_below_40(self):
assert _score_200dma_breadth(35) == 100
def test_healthy_above_70(self):
assert _score_200dma_breadth(75) == 5
def test_calibration_62_26(self):
"""62.26% breadth should score ~24 (healthy)."""
score = _score_200dma_breadth(62.26)
assert 18 <= score <= 30 # Reasonable range around 24
def test_boundary_50(self):
score = _score_200dma_breadth(50)
assert score == 55
def test_boundary_60(self):
score = _score_200dma_breadth(60)
assert score == 30
class TestCalculateBreadthDivergence:
"""Integration tests."""
def test_missing_breadth_returns_50(self):
"""None 200DMA → score 50, data_available=False."""
result = calculate_breadth_divergence(None, None, -2.0)
assert result["score"] == 50
assert result["data_available"] is False
def test_available_data_has_flag(self):
"""Valid data → data_available=True."""
result = calculate_breadth_divergence(65.0, 55.0, -1.0)
assert result["data_available"] is True
def test_near_highs_divergence(self):
"""Index near highs + weak breadth → high score."""
result = calculate_breadth_divergence(45.0, 30.0, -2.0)
assert result["score"] >= 70
assert result["divergence_detected"] is True
def test_not_near_highs_halved(self):
"""Index NOT near highs → score halved."""
near = calculate_breadth_divergence(45.0, None, -2.0)
far = calculate_breadth_divergence(45.0, None, -8.0)
assert far["score"] < near["score"]
def test_50dma_supplement_boost(self):
"""Low 50DMA breadth at highs adds +10."""
base = calculate_breadth_divergence(55.0, 80.0, -2.0)
boosted = calculate_breadth_divergence(55.0, 25.0, -2.0)
assert boosted["score"] > base["score"]
def test_auto_fetched_breadth_source_marker(self):
"""breadth_source can be set on comp4 result after calculate."""
result = calculate_breadth_divergence(64.81, None, -1.0)
result["breadth_source"] = "auto"
result["breadth_auto_date"] = "2026-02-18"
assert result["breadth_source"] == "auto"
assert result["breadth_auto_date"] == "2026-02-18"
assert result["data_available"] is True
"""
Market Top Detector - Shared Utilities
Common helper functions used across multiple modules.
"""
from datetime import date, timedelta
def count_business_days(start_date: date, end_date: date) -> int:
"""Count business days between start (exclusive) and end (inclusive).
Friday→Monday = 1 business day.
Returns -1 if start_date > end_date (future date).
"""
if start_date > end_date:
return -1
count = 0
current = start_date
while current < end_date:
current += timedelta(days=1)
if current.weekday() < 5:
count += 1
return count
Related skills
FAQ
What defines a Distribution Day in Market Top Detector?
Market Top Detector defines a Distribution Day when a major index (S&P 500 or NASDAQ Composite) declines by 0.2% or more from the previous session's close AND volume exceeds the previous day's volume. Both criteria must be met.
Which indexes does Market Top Detector analyze?
Market Top Detector applies William O'Neil distribution and stalling day rules to major stock indexes, specifically the S&P 500 and NASDAQ Composite. The skill judges whether an uptrend faces institutional selling pressure.
Is Market Top Detector safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.