
Uptrend Analyzer
- 970 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
uptrend-analyzer is a trading analysis skill that determines whether a market sector or the overall market is in a confirmed uptrend for developers who must validate regime before committing capital or building trading t
About
uptrend-analyzer is an agent skill that applies Monty's Uptrend Ratio methodology to classify US equity market conditions before trading decisions. It reads daily CSV data from the tradermonty/uptrend-dashboard GitHub repository, which tracks roughly 2,800 US stocks across 11 GICS sectors and flags uptrend names using Finviz Elite screener rules. A stock counts as uptrend only when price exceeds $10, average volume tops 100K, and all additional Finviz criteria pass simultaneously. Developers reach for uptrend-analyzer when building trading agents or dashboards that need a sector-level or market-wide uptrend confirmation gate before entries or strategy deployment.
- Analyzes Monty's daily Uptrend Ratio from 2,800 US stocks across 11 GICS sectors
- Applies strict 8-criteria Finviz Elite screener including golden cross and 52-week recovery
- Delivers both overall market and sector-level uptrend signals with historical context
- Consumes live CSV data from tradermonty/uptrend-dashboard updated daily
- Provides clear bullish-structure confirmation before any trading strategy or product decision
Uptrend Analyzer by the numbers
- 970 all-time installs (skills.sh)
- +87 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #306 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill uptrend-analyzerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 970 |
|---|---|
| repo stars | ★ 2.6k |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you confirm a market sector uptrend before trading?
Quickly determine whether a market sector or the overall market is in a confirmed uptrend before committing capital or building trading tools.
Who is it for?
Quant developers and trading tool builders who gate entries on confirmed sector or market-wide uptrend regimes.
Skip if: Long-term investors who ignore technical trend filters or developers building non-US equity strategies without Finviz-compatible data.
When should I use this skill?
User asks whether a sector or the market is in an uptrend before trading or building a trend-filtered strategy.
What you get
Sector and market uptrend ratio readings with per-stock uptrend classifications from dashboard CSV data.
- sector uptrend ratios
- market regime classification
- stock uptrend flags
By the numbers
- Tracks approximately 2,800 US stocks across 11 GICS sectors
- Requires price > $10 and average volume > 100K for uptrend classification
Files
Uptrend Analyzer Skill
Purpose
Diagnose market breadth health using Monty's Uptrend Ratio Dashboard, which tracks ~2,800 US stocks across 11 sectors. Generates a 0-100 composite score (higher = healthier) with exposure guidance.
Unlike the Market Top Detector (API-based risk scorer), this skill uses free CSV data to assess "participation breadth" - whether the market's advance is broad or narrow.
When to Use This Skill
English:
- User asks "Is the market breadth healthy?" or "How broad is the rally?"
- User wants to assess uptrend ratios across sectors
- User asks about market participation or breadth conditions
- User needs exposure guidance based on breadth analysis
- User references Monty's Uptrend Dashboard or uptrend ratios
Japanese:
- 「市場のブレドスは健全?」「上昇の裾野は広い?」
- セクター別のアップトレンド比率を確認したい
- 相場参加率・ブレドス状況を診断したい
- ブレドス分析に基づくエクスポージャーガイダンスが欲しい
- Montyのアップトレンドダッシュボードについて質問
Prerequisites
- Python 3.9+ with the
requestslibrary (CSV parsing uses the stdlibcsv/iomodules) - Internet connection to fetch CSV data from GitHub (no API key required)
- No paid API subscriptions needed
Difference from Market Top Detector
| Aspect | Uptrend Analyzer | Market Top Detector |
|---|---|---|
| Score Direction | Higher = healthier | Higher = riskier |
| Data Source | Free GitHub CSV | FMP API (paid) |
| Focus | Breadth participation | Top formation risk |
| API Key | Not required | Required (FMP) |
| Methodology | Monty Uptrend Ratios | O'Neil/Minervini/Monty |
---
Execution Workflow
Phase 1: Execute Python Script
Run the analysis script (no API key needed):
python3 skills/uptrend-analyzer/scripts/uptrend_analyzer.pyThe script will: 1. Download CSV data from Monty's GitHub repository 2. Calculate 5 component scores 3. Generate composite score and reports
Phase 2: Present Results
Present the generated Markdown report to the user, highlighting:
- Composite score and zone classification
- Exposure guidance (Full/Normal/Reduced/Defensive/Preservation)
- Sector heatmap showing strongest and weakest sectors
- Key momentum and rotation signals
---
5-Component Scoring System
| # | Component | Weight | Key Signal |
|---|---|---|---|
| 1 | Market Breadth (Overall) | 30% | Ratio level + trend direction |
| 2 | Sector Participation | 25% | Uptrend sector count + ratio spread |
| 3 | Sector Rotation | 15% | Cyclical vs Defensive balance |
| 4 | Momentum | 20% | Slope direction + acceleration |
| 5 | Historical Context | 10% | Percentile rank in history |
Scoring Zones
| Score | Zone | Exposure Guidance |
|---|---|---|
| 80-100 | Strong Bull | Full Exposure (100%) |
| 60-79 | Bull | Normal Exposure (80-100%) |
| 40-59 | Neutral | Reduced Exposure (60-80%) |
| 20-39 | Cautious | Defensive (30-60%) |
| 0-19 | Bear | Capital Preservation (0-30%) |
7-Level Zone Detail
Each scoring zone is further divided into sub-zones for finer-grained assessment:
| Score | Zone Detail | Color |
|---|---|---|
| 80-100 | Strong Bull | Green |
| 70-79 | Bull-Upper | Light Green |
| 60-69 | Bull-Lower | Light Green |
| 40-59 | Neutral | Yellow |
| 30-39 | Cautious-Upper | Orange |
| 20-29 | Cautious-Lower | Orange |
| 0-19 | Bear | Red |
Warning System
Active warnings trigger exposure penalties that tighten guidance even when the composite score is high:
| Warning | Condition | Penalty |
|---|---|---|
| Late Cycle | Commodity avg > both Cyclical and Defensive | -5 |
| High Spread | Max-min sector ratio spread > 40pp | -3 |
| Divergence | Intra-group std > 8pp, spread > 20pp, or trend dissenters | -3 |
Penalties stack (max -10) + multi-warning discount (+1 when ≥2 active). Applied after composite scoring.
Momentum Smoothing
Slope values are smoothed using EMA(3) (Exponential Moving Average, span=3) before scoring. Acceleration is calculated by comparing the recent 10-point average vs prior 10-point average of smoothed slopes (10v10 window), with fallback to 5v5 when fewer than 20 data points are available.
Historical Confidence Indicator
The Historical Context component includes a confidence assessment based on:
- Sample size: Number of historical data points available
- Regime coverage: Proportion of distinct market regimes (bull/bear/neutral) observed
- Recency: How recent the latest data point is
Confidence levels: High, Medium, Low.
---
API Requirements
Required: None (uses free GitHub CSV data)
Output Files
- JSON:
uptrend_analysis_YYYY-MM-DD_HHMMSS.json - Markdown:
uptrend_analysis_YYYY-MM-DD_HHMMSS.md
Reference Documents
references/uptrend_methodology.md
- Uptrend Ratio definition and thresholds
- 5-component scoring methodology
- Sector classification (Cyclical/Defensive/Commodity)
- Historical calibration notes
When to Load References
- First use: Load
uptrend_methodology.mdfor full framework understanding - Regular execution: References not needed - script handles scoring
Uptrend Analyzer Methodology
Data Source: Monty's Uptrend Ratio Dashboard
Monty's Uptrend Ratio Dashboard tracks approximately 2,800 US stocks across 11 GICS sectors. For each stock, it determines whether the stock is in an "uptrend" based on the criteria below. The dashboard publishes daily CSV data on GitHub.
GitHub Repository: tradermonty/uptrend-dashboard Live Dashboard: https://uptrend-dashboard.streamlit.app/
Uptrend Definition (Finviz Elite Screener)
A stock is classified as "uptrend" when it meets all of the following conditions:
| Condition | Description |
|---|---|
| Price > $10 | Penny stocks excluded |
| Avg Volume > 100K | Sufficient liquidity |
| Market Cap > $50M | Micro-cap and above |
| Price > SMA20 | Short-term uptrend |
| Price > SMA200 | Long-term uptrend |
| SMA50 > SMA200 | Golden cross (bullish structure) |
| 52W High/Low > 30% above Low | Recovering from bottom |
| 4-Week Performance: Up | Recent momentum positive |
The uptrend ratio = (stocks meeting all conditions) / (stocks meeting base filters: price, volume, market cap).
CSV Files
| File | Description | Update Frequency |
|---|---|---|
uptrend_ratio_timeseries.csv | Daily ratios for "all" + 11 sectors | Daily |
sector_summary.csv | Latest snapshot of all sectors | Daily |
Data availability:
- "all" (full market): Since 2023-08-11
- Sector-level data: Since 2024-07-21
Timeseries Columns
| Column | Type | Description |
|---|---|---|
| worksheet | string | "all" or sector slug (e.g., "sec_technology") |
| date | string | YYYY-MM-DD format |
| count | int | Number of stocks in uptrend |
| total | int | Total stocks tracked |
| ratio | float | count/total (0-1 scale, raw decimal) |
| ma_10 | float | 10-day simple moving average of ratio |
| slope | float | 1-day difference of ma_10 (ma_10.diff()) |
| trend | string | "up" (slope > 0) or "down" (slope <= 0) |
Sector Summary Columns
| Column | Type | Description |
|---|---|---|
| Sector | string | Display name (e.g., "Technology") |
| Ratio | float | Current uptrend ratio (0-1) |
| 10MA | float | 10-day MA of ratio |
| Trend | string | "Up" or "Down" |
| Slope | float | 1-day difference of MA |
| Status | string | "Overbought", "Oversold", or "Normal" |
Indicator Calculations (from source code)
All indicators are computed on-the-fly from raw count/total data:
ratio = count / total
ma_10 = ratio.rolling(10).mean() # 10-day simple MA
slope = ma_10.diff() # 1-day change of MA
trend = "up" if slope > 0 else "down"Peak/Trough Detection: The dashboard uses scipy.signal.find_peaks with parameters distance=20, prominence=0.015 to identify local tops and bottoms in the 10MA series.
---
Official Dashboard Thresholds
These thresholds are defined in src/constants.py of the source repository:
| Threshold | Value | Meaning |
|---|---|---|
| Upper (Overbought) | 37% | Ratio above this = overbought conditions |
| Lower (Oversold) | 9.7% | Ratio below this = oversold / crisis |
| MA Period | 10 | Simple moving average window |
Status Determination
ratio > 0.37 -> "Overbought"
ratio < 0.097 -> "Oversold"
otherwise -> "Normal"Practical Interpretation
| Ratio | Interpretation | Market Environment |
|---|---|---|
| 50%+ | Strong breadth | Broad bull market, most stocks participating |
| 37-50% | Overbought / Healthy | Above upper threshold, strong but extended |
| 25-37% | Normal / Recovering | Between thresholds, typical trading range |
| 9.7-25% | Weak | Below normal, breadth deteriorating |
| < 9.7% | Oversold / Crisis | Below lower threshold, extreme selling |
---
5-Component Scoring System
Component 1: Market Breadth (Overall) - Weight: 30%
Rationale: The overall uptrend ratio is the single most important measure of market health. A high ratio means broad participation; a low ratio means a narrow, fragile market.
Scoring Bands (aligned with dashboard thresholds):
| Ratio | Score Range | Signal |
|---|---|---|
| >= 50% | 90-100 | Strong Bull |
| 37-50% | 70-89 | Bullish (above overbought threshold) |
| 25-37% | 40-69 | Neutral/Recovering |
| 9.7-25% | 10-39 | Weak (between thresholds) |
| < 9.7% | 0-9 | Crisis (below oversold threshold) |
Trend Adjustment: +5 when trend="up" and slope>0, -5 when trend="down" and slope<0.
Component 2: Sector Participation - Weight: 25%
Rationale: A healthy market has most sectors participating. When only 2-3 sectors lead, the market is fragile and vulnerable to sector rotation shocks.
Sub-scores:
- Uptrend Count (60%): Number of sectors in uptrend mapped to 0-100
- Spread (40%): Max-min ratio spread. Narrow spread = uniform participation (good). Wide spread = selective market (risky).
Overbought/Oversold classification uses dashboard thresholds: >37% = Overbought, <9.7% = Oversold.
Component 3: Sector Rotation - Weight: 15%
Rationale: In a healthy bull market, cyclical sectors (Technology, Consumer Cyclical) lead defensive sectors (Utilities, Consumer Defensive). When defensives lead, it signals risk-off behavior.
Sector Classification:
| Group | Sectors |
|---|---|
| Cyclical | Technology, Consumer Cyclical, Communication Services, Financial, Industrials |
| Defensive | Utilities, Consumer Defensive, Healthcare, Real Estate |
| Commodity | Energy, Basic Materials |
Scoring: Based on cyclical_avg - defensive_avg difference.
- Cyclical lead > +15pp = Strong risk-on (90-100)
- Balanced within +/-5pp = Neutral (45-69)
- Defensive lead > +15pp = Strong risk-off (0-19)
Commodity Adjustment: When commodity sectors outperform both cyclical and defensive groups, it may signal late-cycle dynamics. A penalty of -5 to -10 is applied.
Intra-Group Divergence Detection: The system detects significant dispersion within Cyclical and Defensive groups. A divergence flag triggers when any of:
- Group internal standard deviation > 8 percentage points
- Group internal max-min spread > 20 percentage points
- One or more sectors trend in the opposite direction from the group majority
When divergence is detected, a -5 penalty is applied to the Component 3 score. This prevents group averages from masking important internal disagreements (e.g., Financial declining while other Cyclicals rise).
Note (Dual-Layer Penalty): Divergence triggers penalties at two levels:
(1) -5 to the Component 3 score (making the rotation score more truthful), and
(2) -3 to the composite score (triggering exposure guidance tightening, see Warning System below).
Net composite impact ≈ 5 × 0.15 (weight) + 3 = 3.75 points.
Component 4: Momentum - Weight: 20%
Rationale: The direction and rate of change in breadth matters as much as the level. Improving breadth (positive slope, accelerating) suggests the environment is getting better; deteriorating breadth suggests caution.
Sub-scores:
- Slope Score (50%): Smoothed slope (EMA-3) mapped to 0-100 (typical range: -0.02 to +0.02). The raw 1-day slope (
ma_10.diff()) is smoothed with a 3-period Exponential Moving Average to reduce daily noise. - Acceleration (30%): Recent 10-point smoothed slope average vs prior 10-point average (10v10 window). Falls back to 5v5 when fewer than 20 data points are available.
- Sector Slope Breadth (20%): Count of sectors with positive slope
Momentum Smoothing: The raw slope signal from Monty's dashboard is inherently noisy because it is a 1-day difference of a 10-day moving average. The EMA(3) smoothing filter reduces single-day volatility while preserving directional trends. Both raw and smoothed slope values are reported for transparency.
Component 5: Historical Context - Weight: 10%
Rationale: Knowing where the current ratio falls in historical distribution provides perspective. A ratio that seems "low" might be historically average, or vice versa.
Scoring: Percentile rank of current ratio in the full historical distribution (Aug 2023 to present).
Note: The "all" dataset starts from 2023-08-11 (~650+ data points), while sector data starts from 2024-07-21 (~370+ data points each).
Confidence Assessment: Because the historical dataset is limited (~650 data points), the system assesses and reports the confidence level of percentile analysis:
| Factor | Criteria | Score |
|---|---|---|
| Sample Size | >=1000: full (3), 500-999: moderate (2), 200-499: limited (1), <200: minimal (0) | 0-3 |
| Regime Coverage | Has bear data (min<10%) AND bull data (max>40%): Both (2), one: Partial (1), neither: Narrow (0) | 0-2 |
| Recency Bias | Recent 90 days cover >=30% of full range: balanced (1), otherwise: biased (0) | 0-1 |
Total score 5-6 = High, 3-4 = Moderate, 1-2 = Low, 0 = Very Low confidence.
When confidence is Low or Very Low, the signal text includes a [confidence: Low] caveat.
---
Scoring Zones and Exposure Guidance
5-Level Zones (backward-compatible)
| Score | Zone | Exposure | Description |
|---|---|---|---|
| 80-100 | Strong Bull | Full (100%) | Broad participation, strong momentum. Ideal for aggressive positioning. |
| 60-79 | Bull | Normal (80-100%) | Healthy breadth. Standard position management. |
| 40-59 | Neutral | Reduced (60-80%) | Mixed signals. Participate selectively. |
| 20-39 | Cautious | Defensive (30-60%) | Weak breadth. Prioritize capital preservation. |
| 0-19 | Bear | Preservation (0-30%) | Severe deterioration. Maximum defense. |
7-Level Zone Detail
The zone_detail field provides finer granularity, splitting the Bull and Cautious zones:
| Score | Zone (5-level) | Zone Detail (7-level) | Exposure Range |
|---|---|---|---|
| 80-100 | Strong Bull | Strong Bull | 100% |
| 70-79 | Bull | Bull-Upper | 90-100% |
| 60-69 | Bull | Bull-Lower | 80-90% |
| 40-59 | Neutral | Neutral | 60-80% |
| 30-39 | Cautious | Cautious-Upper | 45-60% |
| 20-29 | Cautious | Cautious-Lower | 30-45% |
| 0-19 | Bear | Bear | 0-30% |
Why: The original Bull zone (60-79) was too wide. A score of 66 and 78 both showed "Bull" but warrant different exposure levels. Bull-Lower signals caution compared to Bull-Upper.
Zone Proximity Indicator
When the composite score falls within 10 points of a zone boundary (20, 40, 60, 80), the zone_proximity field flags at_boundary=True with the distance and direction. This alerts users that a small score change could shift the zone classification.
---
Warning System
Component-Level Warnings
Warnings detect conditions where the composite score may overstate market health:
| Warning | Trigger | Composite Penalty | Rationale |
|---|---|---|---|
| Late Cycle | Commodity avg > both Cyclical and Defensive group averages | -5 | Commodity leadership often precedes broader market weakness |
| High Spread | Max-min sector ratio spread > 40pp | -3 | Wide spread indicates narrowing leadership masked by averages |
| Divergence | Intra-group std > 8pp, spread > 20pp, or trend dissenters | -3 | Group averages hide internal disagreement (also -5 to Component 3 score) |
Multi-Warning Discount
When 2 or more warnings are active simultaneously, the total penalty is reduced by 1 point to account for correlation between warning conditions.
Example: Late Cycle (-5) + High Spread (-3) = -8 + discount (+1) = -7 total penalty
Warning Impact on Guidance
When warnings are active in Bull or Strong Bull zones:
- Exposure guidance is tightened (e.g., "Normal Exposure, Lower End (80-90%)" instead of "Normal Exposure (80-100%)")
- Guidance text notes the tension between score and warnings
- Warning-specific actions are prepended to recommended actions
The raw score (before penalty) is preserved as composite_score_raw for transparency.
---
Weight Rationale
| Component | Weight | Rationale |
|---|---|---|
| Market Breadth | 30% | Most direct measure of market health |
| Sector Participation | 25% | Breadth of sector-level participation is critical for sustainability |
| Momentum | 20% | Direction matters as much as level |
| Sector Rotation | 15% | Rotation signals provide important risk-on/off context |
| Historical Context | 10% | Provides perspective but less actionable than real-time signals |
---
Limitations
1. Data History: "all" data starts from Aug 2023; sector data from Jul 2024. Long-term percentile analysis is limited. The confidence indicator helps quantify this limitation. 2. Single Source: Relies entirely on Monty's dashboard (Finviz Elite data); no cross-validation with other breadth measures. 3. No Volume Data: Uptrend ratio is price-based only; no volume confirmation. 4. Lagging Indicator: 10-day moving average and slope introduce inherent lag. EMA(3) smoothing adds marginal additional lag but reduces noise. 5. US Only: Covers US stocks only; no international market breadth. 6. Sector Classification: Uses fixed GICS sectors which may not capture all rotation dynamics. 7. Finviz Dependency: Upstream data depends on Finviz Elite availability and screener accuracy.
---
Complementary Analysis
This skill works best when combined with:
- Market Top Detector: For distribution day and leadership deterioration signals
- Technical Analyst: For index-level chart confirmation
- Sector Analyst: For detailed sector rotation analysis
- Market News Analyst: For fundamental catalyst context
# Uptrend Analyzer - Calculator modules
#!/usr/bin/env python3
"""
Component 5: Historical Context - Weight: 10%
Evaluates the current uptrend ratio relative to its full historical
distribution (2023/08~present) using percentile rank.
Data Source: Timeseries "all" worksheet
Score = percentile rank of current ratio in historical distribution
Additional context: min, max, median, 30d avg, 90d avg
"""
from typing import Optional
def calculate_historical_context(all_timeseries: list[dict]) -> dict:
"""
Calculate historical context score via percentile rank.
Args:
all_timeseries: Full "all" timeseries sorted by date ascending
Returns:
Dict with score (0-100), signal, and context fields
"""
if not all_timeseries:
return {
"score": 50,
"signal": "NO DATA: Historical timeseries unavailable (neutral default)",
"data_available": False,
"percentile": None,
"current_ratio": None,
"historical_min": None,
"historical_max": None,
"historical_median": None,
}
# Extract all valid ratios
ratios = [r["ratio"] for r in all_timeseries if r.get("ratio") is not None]
if len(ratios) < 2:
return {
"score": 50,
"signal": "INSUFFICIENT DATA: Need at least 2 data points",
"data_available": False,
"percentile": None,
"current_ratio": None,
"data_points": len(ratios),
}
current_ratio = ratios[-1]
# Calculate percentile rank
below_count = sum(1 for r in ratios if r < current_ratio)
equal_count = sum(1 for r in ratios if r == current_ratio)
percentile = (below_count + equal_count * 0.5) / len(ratios) * 100
percentile = round(percentile, 1)
# Score = percentile rank (direct mapping)
score = round(min(100, max(0, percentile)))
# Historical statistics
sorted_ratios = sorted(ratios)
hist_min = sorted_ratios[0]
hist_max = sorted_ratios[-1]
n = len(sorted_ratios)
if n % 2 == 0:
hist_median = (sorted_ratios[n // 2 - 1] + sorted_ratios[n // 2]) / 2
else:
hist_median = sorted_ratios[n // 2]
# Recent averages
avg_30d = _avg_last_n(ratios, 30)
avg_90d = _avg_last_n(ratios, 90)
signal = _build_signal(score, percentile, current_ratio)
# Assess confidence of historical analysis
confidence = _assess_confidence(ratios)
# Append confidence caveat to signal if low
if confidence["confidence_level"] in ("Low", "Very Low"):
signal += f" [confidence: {confidence['confidence_level']}]"
return {
"score": score,
"signal": signal,
"data_available": True,
"percentile": percentile,
"current_ratio": current_ratio,
"current_ratio_pct": round(current_ratio * 100, 1),
"historical_min": round(hist_min, 4),
"historical_min_pct": round(hist_min * 100, 1),
"historical_max": round(hist_max, 4),
"historical_max_pct": round(hist_max * 100, 1),
"historical_median": round(hist_median, 4),
"historical_median_pct": round(hist_median * 100, 1),
"avg_30d": round(avg_30d, 4) if avg_30d is not None else None,
"avg_30d_pct": round(avg_30d * 100, 1) if avg_30d is not None else None,
"avg_90d": round(avg_90d, 4) if avg_90d is not None else None,
"avg_90d_pct": round(avg_90d * 100, 1) if avg_90d is not None else None,
"data_points": len(ratios),
"date_range": f"{all_timeseries[0].get('date', '?')} to {all_timeseries[-1].get('date', '?')}",
"confidence": confidence,
}
def _assess_confidence(ratios: list[float]) -> dict:
"""Assess confidence level of percentile analysis.
Evaluates:
- Sample size: >=1000 full, 500-999 moderate, 200-499 limited, <200 minimal
- Regime coverage: Has bear data (min<10%) AND bull data (max>40%)
- Recency bias: Whether recent 90 days span less than 30% of full range
Returns:
Dict with confidence_level, confidence_score, regime details
"""
n = len(ratios)
# Sample size assessment
if n >= 1000:
size_score = 3
size_label = "full"
elif n >= 500:
size_score = 2
size_label = "moderate"
elif n >= 200:
size_score = 1
size_label = "limited"
else:
size_score = 0
size_label = "minimal"
# Regime coverage
hist_min = min(ratios) if ratios else 0
hist_max = max(ratios) if ratios else 0
has_bear = hist_min < 0.10 # Below 10%
has_bull = hist_max > 0.40 # Above 40%
if has_bear and has_bull:
regime_coverage = "Both"
regime_score = 2
elif has_bear or has_bull:
regime_coverage = "Partial"
regime_score = 1
else:
regime_coverage = "Narrow"
regime_score = 0
# Recency bias
full_range = hist_max - hist_min if hist_max != hist_min else 1.0
recent_90 = ratios[-90:] if len(ratios) >= 90 else ratios
recent_range = max(recent_90) - min(recent_90) if len(recent_90) >= 2 else 0
recency_ratio = recent_range / full_range if full_range > 0 else 0
if recency_ratio >= 0.30:
recency_label = "balanced"
recency_score = 1
else:
recency_label = "biased"
recency_score = 0
# Overall confidence
total_score = size_score + regime_score + recency_score
if total_score >= 5:
confidence_level = "High"
elif total_score >= 3:
confidence_level = "Moderate"
elif total_score >= 1:
confidence_level = "Low"
else:
confidence_level = "Very Low"
return {
"confidence_level": confidence_level,
"confidence_score": total_score,
"sample_size": n,
"sample_label": size_label,
"has_bear_data": has_bear,
"has_bull_data": has_bull,
"regime_coverage": regime_coverage,
"recency_label": recency_label,
}
def _avg_last_n(values: list[float], n: int) -> Optional[float]:
"""Average of the last n values, or all if fewer than n."""
if not values:
return None
subset = values[-n:]
return sum(subset) / len(subset)
def _build_signal(score: int, percentile: float, current_ratio: float) -> str:
"""Build human-readable signal."""
ratio_pct = round(current_ratio * 100, 1)
if score >= 80:
return f"ABOVE AVERAGE: {ratio_pct}% at {percentile}th percentile historically"
elif score >= 60:
return f"SLIGHTLY ABOVE: {ratio_pct}% at {percentile}th percentile historically"
elif score >= 40:
return f"NEAR MEDIAN: {ratio_pct}% at {percentile}th percentile historically"
elif score >= 20:
return f"BELOW AVERAGE: {ratio_pct}% at {percentile}th percentile historically"
else:
return f"HISTORICALLY LOW: {ratio_pct}% at {percentile}th percentile historically"
#!/usr/bin/env python3
"""
Component 1: Market Breadth (Overall) - Weight: 30%
Evaluates the overall market uptrend ratio level and trend direction.
Primary signal: ratio level mapped to scoring bands with trend adjustment.
Data Source: Timeseries "all" worksheet
Thresholds aligned with Monty's dashboard:
- Overbought: 37% (upper threshold)
- Oversold: 9.7% (lower threshold)
Scoring Bands (linear interpolation within each band):
>= 50% -> 90-100 (Strong Bull)
37-50% -> 70-89 (Bullish / above overbought threshold)
25-37% -> 40-69 (Neutral/Recovering)
9.7-25% -> 10-39 (Weak / between thresholds)
< 9.7% -> 0-9 (Crisis / below oversold threshold)
Trend adjustment: trend="up" & slope>0 -> +5, trend="down" & slope<0 -> -5
"""
from typing import Optional
# Monty's official dashboard thresholds
UPPER_THRESHOLD = 0.37 # Overbought
LOWER_THRESHOLD = 0.097 # Oversold
def calculate_market_breadth(latest_all: Optional[dict], all_timeseries: list[dict]) -> dict:
"""
Calculate market breadth score from overall uptrend ratio.
Args:
latest_all: Most recent "all" timeseries row
all_timeseries: Full "all" timeseries sorted by date ascending
Returns:
Dict with score (0-100), signal, and detail fields
"""
if not latest_all or latest_all.get("ratio") is None:
return {
"score": 50,
"signal": "NO DATA: Overall uptrend ratio unavailable (neutral default)",
"data_available": False,
"ratio": None,
"ma_10": None,
"trend": None,
"slope": None,
"distance_from_upper": None,
"distance_from_lower": None,
}
ratio = latest_all["ratio"]
ma_10 = latest_all.get("ma_10")
trend = latest_all.get("trend", "")
slope = latest_all.get("slope")
# Map ratio to base score via linear interpolation
base_score = _ratio_to_score(ratio)
# Trend adjustment
trend_adj = 0
if trend.lower() == "up" and slope is not None and slope > 0:
trend_adj = 5
elif trend.lower() == "down" and slope is not None and slope < 0:
trend_adj = -5
score = round(min(100, max(0, base_score + trend_adj)))
# Signal label
signal = _score_to_signal(score, ratio, trend)
# Key distances from Monty's official thresholds
distance_from_upper = round(ratio - UPPER_THRESHOLD, 4) if ratio is not None else None
distance_from_lower = round(ratio - LOWER_THRESHOLD, 4) if ratio is not None else None
return {
"score": score,
"signal": signal,
"data_available": True,
"ratio": ratio,
"ratio_pct": round(ratio * 100, 1) if ratio is not None else None,
"ma_10": ma_10,
"ma_10_pct": round(ma_10 * 100, 1) if ma_10 is not None else None,
"trend": trend,
"slope": slope,
"trend_adjustment": trend_adj,
"distance_from_upper": distance_from_upper,
"distance_from_lower": distance_from_lower,
"upper_threshold": UPPER_THRESHOLD,
"lower_threshold": LOWER_THRESHOLD,
"date": latest_all.get("date", "N/A"),
}
def _ratio_to_score(ratio: float) -> float:
"""Map uptrend ratio (0-1 scale) to base score (0-100).
Aligned with Monty's dashboard thresholds:
Upper (Overbought) = 0.37, Lower (Oversold) = 0.097
Scoring bands with linear interpolation:
>= 0.50 -> 90-100 (Strong Bull)
0.37-0.50 -> 70-89 (Bullish, above overbought)
0.25-0.37 -> 40-69 (Neutral/Recovering)
0.097-0.25 -> 10-39 (Weak, between thresholds)
< 0.097 -> 0-9 (Crisis, below oversold)
"""
if ratio >= 0.50:
# 0.50 -> 90, 0.60+ -> 100
return min(100, 90 + (ratio - 0.50) / 0.10 * 10)
elif ratio >= UPPER_THRESHOLD:
# 0.37 -> 70, 0.50 -> 89
return 70 + (ratio - UPPER_THRESHOLD) / (0.50 - UPPER_THRESHOLD) * 19
elif ratio >= 0.25:
# 0.25 -> 40, 0.37 -> 69
return 40 + (ratio - 0.25) / (UPPER_THRESHOLD - 0.25) * 29
elif ratio >= LOWER_THRESHOLD:
# 0.097 -> 10, 0.25 -> 39
return 10 + (ratio - LOWER_THRESHOLD) / (0.25 - LOWER_THRESHOLD) * 29
else:
# 0 -> 0, 0.097 -> 9
return max(0, ratio / LOWER_THRESHOLD * 9)
def _score_to_signal(score: int, ratio: float, trend: str) -> str:
"""Map score to human-readable signal."""
ratio_pct = round(ratio * 100, 1)
trend_label = f", trend {trend}" if trend else ""
if score >= 90:
return f"STRONG BULL: {ratio_pct}% uptrend ratio{trend_label}"
elif score >= 70:
return f"BULLISH: {ratio_pct}% uptrend ratio{trend_label}"
elif score >= 45:
return f"NEUTRAL: {ratio_pct}% uptrend ratio{trend_label}"
elif score >= 30:
return f"WEAK: {ratio_pct}% uptrend ratio{trend_label}"
elif score >= 10:
return f"VERY WEAK: {ratio_pct}% uptrend ratio{trend_label}"
else:
return f"CRISIS: {ratio_pct}% uptrend ratio{trend_label}"
#!/usr/bin/env python3
"""
Component 4: Momentum - Weight: 20%
Evaluates the rate of change (slope) and acceleration of the overall
uptrend ratio, plus sector-level slope breadth.
Data Source: Timeseries "all" + Sector Summary
Sub-scores:
- Slope Score (50%): EMA(3)-smoothed current slope mapped to 0-100
- Acceleration (30%): EMA(3)-smoothed slope series, 10v10 window (fallback 5v5)
- Sector Slope Breadth (20%): Count of sectors with positive slope
"""
from typing import Optional
def calculate_momentum(all_timeseries: list[dict], sector_summary: list[dict]) -> dict:
"""
Calculate momentum score from slope, acceleration, and sector breadth.
Args:
all_timeseries: Full "all" timeseries sorted by date ascending
sector_summary: Latest sector summary rows
Returns:
Dict with score (0-100), signal, and detail fields
"""
if not all_timeseries:
return {
"score": 50,
"signal": "NO DATA: Timeseries unavailable (neutral default)",
"data_available": False,
"slope": None,
"acceleration": None,
"sector_slope_breadth": None,
}
latest = all_timeseries[-1]
current_slope = latest.get("slope")
# Extract slope series for smoothing
slopes = [r.get("slope") for r in all_timeseries if r.get("slope") is not None]
# Apply EMA(3) smoothing to slope series
smoothed_slope = None
if slopes:
smoothed_slopes = _ema(slopes, span=3)
smoothed_slope = smoothed_slopes[-1] if smoothed_slopes else current_slope
else:
smoothed_slopes = []
# Sub-score 1: Slope Score (50%) - use smoothed slope
effective_slope = smoothed_slope if smoothed_slope is not None else current_slope
slope_score = _score_slope(effective_slope) if effective_slope is not None else 50
# Sub-score 2: Acceleration (30%) - use smoothed slopes with 10v10 window
accel_score, accel_value, accel_label = _score_acceleration_smoothed(smoothed_slopes)
# Sub-score 3: Sector Slope Breadth (20%)
breadth_score, positive_slope_count, total_sectors = _score_sector_slope_breadth(sector_summary)
# Composite
raw_score = slope_score * 0.50 + accel_score * 0.30 + breadth_score * 0.20
score = round(min(100, max(0, raw_score)))
signal = _build_signal(score, effective_slope, accel_label)
return {
"score": score,
"signal": signal,
"data_available": True,
"slope": current_slope,
"slope_smoothed": round(smoothed_slope, 6) if smoothed_slope is not None else None,
"slope_smoothing": "EMA(3)",
"slope_score": round(slope_score),
"acceleration": accel_value,
"acceleration_label": accel_label,
"acceleration_score": round(accel_score),
"acceleration_window": "10v10",
"sector_positive_slope_count": positive_slope_count,
"sector_total": total_sectors,
"sector_slope_breadth_score": round(breadth_score),
"date": latest.get("date", "N/A"),
}
def _ema(values: list[float], span: int = 3) -> list[float]:
"""Calculate Exponential Moving Average.
Args:
values: List of float values
span: EMA span (default 3)
Returns:
List of EMA values, same length as input. Empty list if input is empty.
"""
if not values:
return []
if len(values) == 1:
return [values[0]]
alpha = 2.0 / (span + 1)
result = [values[0]]
for i in range(1, len(values)):
ema_val = alpha * values[i] + (1 - alpha) * result[-1]
result.append(ema_val)
return result
def _classify_acceleration(acceleration: float) -> tuple:
"""Map acceleration value to (score, label)."""
if acceleration > 0.005:
return 90, "strong_accelerating"
elif acceleration > 0.001:
return 75, "accelerating"
elif acceleration > -0.001:
return 50, "steady"
elif acceleration > -0.005:
return 25, "decelerating"
else:
return 10, "strong_decelerating"
def _score_acceleration_smoothed(smoothed_slopes: list[float]) -> tuple:
"""Calculate acceleration from smoothed slopes using 10v10 window.
Compares recent 10-point average vs prior 10-point average of smoothed slopes.
Falls back to original 5v5 if fewer than 20 points available.
Returns: (score, acceleration_value, label)
"""
if len(smoothed_slopes) < 10:
return _score_acceleration_from_values(smoothed_slopes, window=5)
if len(smoothed_slopes) >= 20:
recent_avg = sum(smoothed_slopes[-10:]) / 10
prior_avg = sum(smoothed_slopes[-20:-10]) / 10
else:
# Fallback to 5v5 with smoothed data
return _score_acceleration_from_values(smoothed_slopes, window=5)
acceleration = recent_avg - prior_avg
score, label = _classify_acceleration(acceleration)
return score, round(acceleration, 6), label
def _score_acceleration_from_values(slopes: list[float], window: int = 5) -> tuple:
"""Fallback acceleration using smaller window."""
if len(slopes) < window * 2:
return 50, None, "insufficient_data"
recent_avg = sum(slopes[-window:]) / window
prior_avg = sum(slopes[-window * 2 : -window]) / window
acceleration = recent_avg - prior_avg
score, label = _classify_acceleration(acceleration)
return score, round(acceleration, 6), label
def _score_slope(slope: float) -> float:
"""Map current slope to 0-100 score.
Typical slope range: -0.02 to +0.02
Extreme range: -0.03 to +0.03
>= +0.02 -> 95-100 (strong bullish momentum)
+0.01~+0.02 -> 75-94
0~+0.01 -> 55-74 (mild positive)
-0.01~0 -> 35-54 (mild negative)
-0.02~-0.01 -> 10-34
< -0.02 -> 0-9 (strong bearish momentum)
"""
if slope >= 0.02:
return min(100, 95 + (slope - 0.02) / 0.01 * 5)
elif slope >= 0.01:
return 75 + (slope - 0.01) / 0.01 * 19
elif slope >= 0:
return 55 + slope / 0.01 * 19
elif slope >= -0.01:
return 35 + (slope + 0.01) / 0.01 * 19
elif slope >= -0.02:
return 10 + (slope + 0.02) / 0.01 * 24
else:
return max(0, 9 + (slope + 0.02) / 0.01 * 9)
def _score_sector_slope_breadth(sector_summary: list[dict]) -> tuple:
"""Score based on count of sectors with positive slope.
Returns: (score, positive_count, total_count)
"""
if not sector_summary:
return 50, 0, 0
total = len(sector_summary)
positive = sum(1 for s in sector_summary if s.get("Slope") is not None and s["Slope"] > 0)
if total == 0:
return 50, 0, 0
# Linear mapping: 0 sectors -> 0, all sectors -> 100
score = (positive / total) * 100
return score, positive, total
def _build_signal(score: int, slope: Optional[float], accel_label: str) -> str:
"""Build human-readable signal."""
slope_str = f"slope={slope:.4f}" if slope is not None else "slope=N/A"
accel_str = accel_label.replace("_", " ")
if score >= 80:
return f"STRONG MOMENTUM: {slope_str}, {accel_str}"
elif score >= 60:
return f"POSITIVE MOMENTUM: {slope_str}, {accel_str}"
elif score >= 40:
return f"NEUTRAL MOMENTUM: {slope_str}, {accel_str}"
elif score >= 20:
return f"WEAK MOMENTUM: {slope_str}, {accel_str}"
else:
return f"NEGATIVE MOMENTUM: {slope_str}, {accel_str}"
#!/usr/bin/env python3
"""
Component 2: Sector Participation - Weight: 25%
Evaluates how many sectors are participating in the uptrend and the
uniformity of participation (spread between strongest and weakest).
Data Source: Sector Summary CSV
Sub-scores:
- Uptrend Count (60%): How many of 11 sectors are in uptrend
- Spread (40%): Max-min ratio spread (uniform = healthy, selective = weak)
"""
import sys
from typing import Optional
from data_fetcher import build_summary_from_timeseries
# Monty's official dashboard thresholds
OVERBOUGHT_THRESHOLD = 0.37 # Upper threshold
OVERSOLD_THRESHOLD = 0.097 # Lower threshold
def calculate_sector_participation(
sector_summary: list[dict], sector_timeseries: dict[str, list[dict]]
) -> dict:
"""
Calculate sector participation score.
Args:
sector_summary: List of sector summary rows
sector_timeseries: Dict mapping sector -> timeseries rows; used as
fallback if sector_summary is unavailable
Returns:
Dict with score (0-100), signal, and detail fields
"""
if not sector_summary:
if sector_timeseries:
sector_summary = build_summary_from_timeseries(sector_timeseries)
print(" (fallback: built sector summary from timeseries data)", file=sys.stderr)
else:
return {
"score": 50,
"signal": "NO DATA: Sector summary unavailable (neutral default)",
"data_available": False,
"uptrend_count": None,
"total_sectors": None,
"spread": None,
"sector_details": [],
}
total_sectors = len(sector_summary)
if total_sectors == 0:
return {
"score": 50,
"signal": "NO DATA: No sectors in summary",
"data_available": False,
"uptrend_count": 0,
"total_sectors": 0,
"spread": None,
"sector_details": [],
}
# Count sectors in uptrend
uptrend_count = sum(1 for s in sector_summary if s.get("Trend", "").lower() == "up")
# Get ratios for spread calculation
ratios = [s["Ratio"] for s in sector_summary if s.get("Ratio") is not None]
if ratios:
max_ratio = max(ratios)
min_ratio = min(ratios)
spread = max_ratio - min_ratio
else:
max_ratio = None
min_ratio = None
spread = None
# Sub-score 1: Uptrend Count (60%)
count_score = _score_uptrend_count(uptrend_count, total_sectors)
# Sub-score 2: Spread (40%)
spread_score = _score_spread(spread) if spread is not None else 50
# Composite
raw_score = count_score * 0.60 + spread_score * 0.40
score = round(min(100, max(0, raw_score)))
# Identify overbought/oversold sectors
overbought = [
s
for s in sector_summary
if s.get("Ratio") is not None and s["Ratio"] >= OVERBOUGHT_THRESHOLD
]
oversold = [
s for s in sector_summary if s.get("Ratio") is not None and s["Ratio"] < OVERSOLD_THRESHOLD
]
signal = _build_signal(score, uptrend_count, total_sectors, spread)
# Build sector details sorted by ratio descending
sector_details = []
for s in sorted(sector_summary, key=lambda x: x.get("Ratio") or 0, reverse=True):
sector_details.append(
{
"sector": s.get("Sector", "Unknown"),
"ratio": s.get("Ratio"),
"ratio_pct": round(s["Ratio"] * 100, 1) if s.get("Ratio") is not None else None,
"ma_10": s.get("10MA"),
"trend": s.get("Trend", ""),
"slope": s.get("Slope"),
"status": s.get("Status", ""),
"count": s.get("Count"),
"total": s.get("Total"),
}
)
return {
"score": score,
"signal": signal,
"data_available": True,
"uptrend_count": uptrend_count,
"total_sectors": total_sectors,
"count_score": round(count_score),
"spread": round(spread, 4) if spread is not None else None,
"spread_pct": round(spread * 100, 1) if spread is not None else None,
"spread_score": round(spread_score),
"max_ratio": max_ratio,
"min_ratio": min_ratio,
"overbought_count": len(overbought),
"overbought_sectors": [s.get("Sector", "") for s in overbought],
"oversold_count": len(oversold),
"oversold_sectors": [s.get("Sector", "") for s in oversold],
"sector_details": sector_details,
}
def _score_uptrend_count(uptrend_count: int, total_sectors: int) -> float:
"""Score based on number of sectors in uptrend.
10-11 sectors -> 100
8-9 sectors -> 80
6-7 sectors -> 60
4-5 sectors -> 40
2-3 sectors -> 20
0-1 sectors -> 0
"""
if total_sectors == 0:
return 50
if uptrend_count >= 10:
return 100
elif uptrend_count >= 8:
return 80
elif uptrend_count >= 6:
return 60
elif uptrend_count >= 4:
return 40
elif uptrend_count >= 2:
return 20
else:
return 0
def _score_spread(spread: float) -> float:
"""Score based on max-min ratio spread (0-1 scale).
< 0.15 -> 100 (uniform participation)
0.15-0.25 -> 80 (healthy spread)
0.25-0.35 -> 60 (moderate dispersion)
0.35-0.45 -> 30 (wide divergence)
> 0.45 -> 0 (extremely selective)
"""
if spread < 0.15:
return 100
elif spread < 0.25:
return 100 - (spread - 0.15) / 0.10 * 20
elif spread < 0.35:
return 80 - (spread - 0.25) / 0.10 * 20
elif spread < 0.45:
return 60 - (spread - 0.35) / 0.10 * 30
else:
return max(0, 30 - (spread - 0.45) / 0.10 * 30)
def _build_signal(
score: int, uptrend_count: int, total_sectors: int, spread: Optional[float]
) -> str:
"""Build human-readable signal."""
spread_pct = f", spread {round(spread * 100, 1)}%" if spread is not None else ""
if score >= 80:
return (
f"BROAD PARTICIPATION: {uptrend_count}/{total_sectors} sectors uptrending{spread_pct}"
)
elif score >= 60:
return f"HEALTHY: {uptrend_count}/{total_sectors} sectors uptrending{spread_pct}"
elif score >= 40:
return f"MODERATE: {uptrend_count}/{total_sectors} sectors uptrending{spread_pct}"
elif score >= 20:
return f"NARROW: {uptrend_count}/{total_sectors} sectors uptrending{spread_pct}"
else:
return f"VERY NARROW: {uptrend_count}/{total_sectors} sectors uptrending{spread_pct}"
#!/usr/bin/env python3
"""
Component 3: Sector Rotation - Weight: 15%
Evaluates whether cyclical (risk-on) or defensive (risk-off) sectors are
leading, plus commodity sector dynamics for late-cycle detection.
Data Source: Sector Summary CSV
Sector Groups:
Cyclical: Technology, Consumer Cyclical, Communication Services, Financial, Industrials
Defensive: Utilities, Consumer Defensive, Healthcare, Real Estate
Commodity: Energy, Basic Materials
Primary score: cyclical_avg - defensive_avg difference
> +0.15 -> 90-100 (strong risk-on)
+0.05~+0.15 -> 70-89 (healthy cyclical lead)
-0.05~+0.05 -> 45-69 (balanced)
-0.15~-0.05 -> 20-44 (defensive tilt)
< -0.15 -> 0-19 (strong risk-off)
Commodity adjustment: if commodity_avg > both groups -> late cycle flag, penalty
"""
import math
import sys
from data_fetcher import build_summary_from_timeseries
# Sector classification
CYCLICAL_SECTORS = [
"Technology",
"Consumer Cyclical",
"Communication Services",
"Financial",
"Industrials",
]
DEFENSIVE_SECTORS = [
"Utilities",
"Consumer Defensive",
"Healthcare",
"Real Estate",
]
COMMODITY_SECTORS = [
"Energy",
"Basic Materials",
]
def calculate_sector_rotation(
sector_summary: list[dict], sector_timeseries: dict[str, list[dict]]
) -> dict:
"""
Calculate sector rotation score.
Args:
sector_summary: List of sector summary rows
sector_timeseries: Dict mapping sector -> timeseries rows (reserved)
Returns:
Dict with score (0-100), signal, and detail fields
"""
if not sector_summary:
if sector_timeseries:
sector_summary = build_summary_from_timeseries(sector_timeseries)
print(" (fallback: built sector summary from timeseries data)", file=sys.stderr)
else:
return {
"score": 50,
"signal": "NO DATA: Sector summary unavailable (neutral default)",
"data_available": False,
"cyclical_avg": None,
"defensive_avg": None,
"commodity_avg": None,
"difference": None,
}
# Build lookup by sector name
sector_map = {s["Sector"]: s for s in sector_summary if s.get("Sector")}
# Calculate group averages
cyclical_ratios = _get_group_ratios(sector_map, CYCLICAL_SECTORS)
defensive_ratios = _get_group_ratios(sector_map, DEFENSIVE_SECTORS)
commodity_ratios = _get_group_ratios(sector_map, COMMODITY_SECTORS)
cyclical_avg = _avg(cyclical_ratios) if cyclical_ratios else None
defensive_avg = _avg(defensive_ratios) if defensive_ratios else None
commodity_avg = _avg(commodity_ratios) if commodity_ratios else None
if cyclical_avg is None or defensive_avg is None:
return {
"score": 50,
"signal": "INCOMPLETE DATA: Cannot calculate rotation (neutral default)",
"data_available": False,
"cyclical_avg": cyclical_avg,
"defensive_avg": defensive_avg,
"commodity_avg": commodity_avg,
"difference": None,
}
difference = cyclical_avg - defensive_avg
# Map difference to base score
base_score = _difference_to_score(difference)
# Commodity adjustment: late-cycle penalty
late_cycle_flag = False
commodity_penalty = 0
if commodity_avg is not None:
if commodity_avg > cyclical_avg and commodity_avg > defensive_avg:
late_cycle_flag = True
# Stronger penalty if commodity leads by a lot
excess = commodity_avg - max(cyclical_avg, defensive_avg)
if excess > 0.10:
commodity_penalty = -10
else:
commodity_penalty = -5
# Group divergence detection
divergence_result = _calculate_group_divergence(sector_map)
divergence_penalty = divergence_result.get("divergence_penalty", 0)
score = round(min(100, max(0, base_score + commodity_penalty + divergence_penalty)))
signal = _build_signal(score, difference, late_cycle_flag)
# Build group details
cyclical_details = _build_group_details(sector_map, CYCLICAL_SECTORS)
defensive_details = _build_group_details(sector_map, DEFENSIVE_SECTORS)
commodity_details = _build_group_details(sector_map, COMMODITY_SECTORS)
return {
"score": score,
"signal": signal,
"data_available": True,
"cyclical_avg": round(cyclical_avg, 4),
"cyclical_avg_pct": round(cyclical_avg * 100, 1),
"defensive_avg": round(defensive_avg, 4),
"defensive_avg_pct": round(defensive_avg * 100, 1),
"commodity_avg": round(commodity_avg, 4) if commodity_avg is not None else None,
"commodity_avg_pct": round(commodity_avg * 100, 1) if commodity_avg is not None else None,
"difference": round(difference, 4),
"difference_pct": round(difference * 100, 1),
"late_cycle_flag": late_cycle_flag,
"commodity_penalty": commodity_penalty,
"divergence_flag": divergence_result.get("divergence_flag", False),
"divergence_penalty": divergence_penalty,
"cyclical_divergence": divergence_result.get("cyclical_divergence"),
"defensive_divergence": divergence_result.get("defensive_divergence"),
"cyclical_details": cyclical_details,
"defensive_details": defensive_details,
"commodity_details": commodity_details,
}
def _get_group_ratios(sector_map: dict[str, dict], sector_names: list[str]) -> list[float]:
"""Extract ratios for a group of sectors."""
ratios = []
for name in sector_names:
sector = sector_map.get(name)
if sector and sector.get("Ratio") is not None:
ratios.append(sector["Ratio"])
return ratios
def _avg(values: list[float]) -> float:
"""Simple average."""
return sum(values) / len(values)
def _difference_to_score(diff: float) -> float:
"""Map cyclical-defensive difference to score.
> +0.15 -> 90-100 (strong risk-on)
+0.05~+0.15 -> 70-89 (healthy cyclical lead)
-0.05~+0.05 -> 45-69 (balanced)
-0.15~-0.05 -> 20-44 (defensive tilt)
< -0.15 -> 0-19 (strong risk-off)
"""
if diff > 0.15:
return min(100, 90 + (diff - 0.15) / 0.10 * 10)
elif diff > 0.05:
return 70 + (diff - 0.05) / 0.10 * 19
elif diff > -0.05:
return 45 + (diff + 0.05) / 0.10 * 24
elif diff > -0.15:
return 20 + (diff + 0.15) / 0.10 * 24
else:
return max(0, 19 + (diff + 0.15) / 0.10 * 19)
def _build_signal(score: int, difference: float, late_cycle: bool) -> str:
"""Build human-readable signal."""
diff_pct = round(difference * 100, 1)
late_str = " [LATE CYCLE WARNING]" if late_cycle else ""
if score >= 90:
return f"STRONG RISK-ON: Cyclical leads by {diff_pct}pp{late_str}"
elif score >= 70:
return f"RISK-ON: Cyclical leads by {diff_pct}pp{late_str}"
elif score >= 45:
return f"BALANCED: Cyclical-Defensive gap {diff_pct}pp{late_str}"
elif score >= 20:
return f"DEFENSIVE TILT: Defensive leads by {abs(diff_pct)}pp{late_str}"
else:
return f"STRONG RISK-OFF: Defensive leads by {abs(diff_pct)}pp{late_str}"
def _calculate_group_divergence(sector_map: dict[str, dict]) -> dict:
"""Detect intra-group divergence in Cyclical and Defensive groups.
Divergence flag triggers when any of:
- Group internal std_dev > 8pp (0.08)
- Group internal max-min > 20pp (0.20)
- A sector's trend opposes the majority trend within its group
Returns:
Dict with divergence_flag, divergence_penalty, cyclical_divergence,
defensive_divergence
"""
cyclical_div = _analyze_group(sector_map, CYCLICAL_SECTORS)
defensive_div = _analyze_group(sector_map, DEFENSIVE_SECTORS)
flag = cyclical_div["flagged"] or defensive_div["flagged"]
penalty = -5 if flag else 0
return {
"divergence_flag": flag,
"divergence_penalty": penalty,
"cyclical_divergence": cyclical_div,
"defensive_divergence": defensive_div,
}
def _analyze_group(sector_map: dict[str, dict], sector_names: list[str]) -> dict:
"""Analyze divergence within a sector group."""
ratios = []
trends = []
names_with_data = []
for name in sector_names:
sector = sector_map.get(name)
if sector and sector.get("Ratio") is not None:
ratios.append(sector["Ratio"])
trends.append(sector.get("Trend", "").lower())
names_with_data.append(name)
if len(ratios) < 2:
return {
"flagged": False,
"std_dev": None,
"spread": None,
"outliers": [],
"trend_dissenters": [],
}
mean = sum(ratios) / len(ratios)
variance = sum((r - mean) ** 2 for r in ratios) / len(ratios)
std_dev = math.sqrt(variance)
spread = max(ratios) - min(ratios)
# Outlier detection: sectors more than 1.5 * std_dev from mean
outliers = []
for _i, (name, ratio) in enumerate(zip(names_with_data, ratios)):
if abs(ratio - mean) > 1.5 * std_dev and std_dev > 0:
outliers.append({"sector": name, "ratio": ratio, "deviation": round(ratio - mean, 4)})
# Trend dissenter detection: sectors opposing the majority
trend_dissenters = []
if trends:
up_count = sum(1 for t in trends if t == "up")
down_count = sum(1 for t in trends if t == "down")
majority = "up" if up_count >= down_count else "down"
for name, trend in zip(names_with_data, trends):
if trend and trend != majority:
trend_dissenters.append({"sector": name, "trend": trend, "majority": majority})
# Divergence flag conditions
flagged = std_dev > 0.08 or spread > 0.20 or len(trend_dissenters) > 0
return {
"flagged": flagged,
"std_dev": round(std_dev, 4),
"spread": round(spread, 4),
"outliers": outliers,
"trend_dissenters": trend_dissenters,
}
def _build_group_details(sector_map: dict[str, dict], sector_names: list[str]) -> list[dict]:
"""Build detail rows for a sector group."""
details = []
for name in sector_names:
sector = sector_map.get(name)
if sector:
details.append(
{
"sector": name,
"ratio": sector.get("Ratio"),
"ratio_pct": round(sector["Ratio"] * 100, 1)
if sector.get("Ratio") is not None
else None,
"trend": sector.get("Trend", ""),
"slope": sector.get("Slope"),
}
)
else:
details.append(
{
"sector": name,
"ratio": None,
"ratio_pct": None,
"trend": "N/A",
"slope": None,
}
)
return details
#!/usr/bin/env python3
"""
Uptrend Analyzer - CSV Data Fetcher
Downloads and parses Monty's Uptrend Ratio Dashboard CSV data from GitHub.
No API key required - uses publicly available CSV files.
Data Sources:
- Timeseries: uptrend_ratio_timeseries.csv (all + 11 sectors, 2023/08~present)
- Sector Summary: sector_summary.csv (latest snapshot)
"""
import csv
import io
import sys
from typing import Optional
import requests
TIMESERIES_URL = (
"https://raw.githubusercontent.com/tradermonty/uptrend-dashboard/"
"main/data/uptrend_ratio_timeseries.csv"
)
SECTOR_SUMMARY_URL = (
"https://raw.githubusercontent.com/tradermonty/uptrend-dashboard/main/data/sector_summary.csv"
)
WORKSHEET_TO_DISPLAY = {
"sec_basicmaterials": "Basic Materials",
"sec_communicationservices": "Communication Services",
"sec_consumercyclical": "Consumer Cyclical",
"sec_consumerdefensive": "Consumer Defensive",
"sec_energy": "Energy",
"sec_financial": "Financial",
"sec_healthcare": "Healthcare",
"sec_industrials": "Industrials",
"sec_realestate": "Real Estate",
"sec_technology": "Technology",
"sec_utilities": "Utilities",
}
# Monty's official dashboard thresholds (same as in sector_participation_calculator)
OVERBOUGHT_THRESHOLD = 0.37
OVERSOLD_THRESHOLD = 0.097
def build_summary_from_timeseries(sector_timeseries: dict[str, dict]) -> list[dict]:
"""Build sector_summary-compatible list from timeseries latest rows.
Used as fallback when sector_summary.csv is unavailable.
Args:
sector_timeseries: Dict mapping worksheet name -> latest timeseries row
e.g. {"sec_technology": {"ratio": 0.288, "ma_10": 0.266, ...}}
Returns:
List of dicts matching sector_summary format:
[{"Sector": "Technology", "Ratio": 0.288, "10MA": 0.266, ...}]
"""
rows = []
for ws_name, row in sector_timeseries.items():
display_name = WORKSHEET_TO_DISPLAY.get(ws_name, ws_name)
ratio = row.get("ratio")
status = (
"Overbought"
if ratio is not None and ratio > OVERBOUGHT_THRESHOLD
else "Oversold"
if ratio is not None and ratio < OVERSOLD_THRESHOLD
else "Normal"
)
rows.append(
{
"Sector": display_name,
"Ratio": ratio,
"10MA": row.get("ma_10"),
"Trend": (row.get("trend") or "").capitalize(),
"Slope": row.get("slope"),
"Status": status,
"Count": row.get("count"),
"Total": row.get("total"),
}
)
return rows
class UptrendDataFetcher:
"""Client for Monty's Uptrend Ratio Dashboard CSV data"""
def __init__(self):
self.session = requests.Session()
self._timeseries_cache: Optional[list[dict]] = None
self._sector_summary_cache: Optional[list[dict]] = None
def fetch_timeseries(self) -> list[dict]:
"""Download and parse the timeseries CSV.
Returns:
List of dicts with keys: worksheet, date, count, total, ratio,
ma_10, slope, trend. Numeric fields cast to float/int.
"""
if self._timeseries_cache is not None:
return self._timeseries_cache
try:
response = self.session.get(TIMESERIES_URL, timeout=30)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"WARNING: Failed to fetch timeseries CSV: {e}", file=sys.stderr)
return []
rows = []
reader = csv.DictReader(io.StringIO(response.text))
for row in reader:
parsed = _parse_timeseries_row(row)
if parsed:
rows.append(parsed)
self._timeseries_cache = rows
return rows
def fetch_sector_summary(self) -> list[dict]:
"""Download and parse the sector summary CSV.
Returns:
List of dicts with keys: Sector, Ratio, 10MA, Trend, Slope, Status.
Numeric fields cast to float.
"""
if self._sector_summary_cache is not None:
return self._sector_summary_cache
try:
response = self.session.get(SECTOR_SUMMARY_URL, timeout=30)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"WARNING: Failed to fetch sector summary CSV: {e}", file=sys.stderr)
return []
rows = []
reader = csv.DictReader(io.StringIO(response.text))
for row in reader:
parsed = _parse_sector_summary_row(row)
if parsed:
rows.append(parsed)
self._sector_summary_cache = rows
return rows
def get_all_timeseries(self) -> list[dict]:
"""Get timeseries filtered to worksheet=='all', sorted by date ascending."""
data = self.fetch_timeseries()
filtered = [r for r in data if r["worksheet"] == "all"]
filtered.sort(key=lambda r: r["date"])
return filtered
def get_sector_timeseries(self, sector: str) -> list[dict]:
"""Get timeseries for a specific sector worksheet, sorted by date ascending."""
data = self.fetch_timeseries()
filtered = [r for r in data if r["worksheet"] == sector]
filtered.sort(key=lambda r: r["date"])
return filtered
def get_latest_all(self) -> Optional[dict]:
"""Get the most recent 'all' row."""
ts = self.get_all_timeseries()
return ts[-1] if ts else None
def get_all_sector_latest(self) -> dict[str, dict]:
"""Get latest row for each sector (excluding 'all').
Returns:
Dict mapping sector name -> latest timeseries row
"""
data = self.fetch_timeseries()
sectors: dict[str, dict] = {}
for row in data:
ws = row["worksheet"]
if ws == "all":
continue
if ws not in sectors or row["date"] > sectors[ws]["date"]:
sectors[ws] = row
return sectors
def _parse_timeseries_row(row: dict) -> Optional[dict]:
"""Parse a timeseries CSV row, casting numeric fields."""
try:
return {
"worksheet": row.get("worksheet", "").strip(),
"date": row.get("date", "").strip(),
"count": _safe_int(row.get("count")),
"total": _safe_int(row.get("total")),
"ratio": _safe_float(row.get("ratio")),
"ma_10": _safe_float(row.get("ma_10")),
"slope": _safe_float(row.get("slope")),
"trend": row.get("trend", "").strip(),
}
except (ValueError, TypeError):
return None
def _parse_sector_summary_row(row: dict) -> Optional[dict]:
"""Parse a sector summary CSV row, casting numeric fields."""
try:
return {
"Sector": row.get("Sector", "").strip(),
"Ratio": _safe_float(row.get("Ratio")),
"10MA": _safe_float(row.get("10MA")),
"Trend": row.get("Trend", "").strip(),
"Slope": _safe_float(row.get("Slope")),
"Status": row.get("Status", "").strip(),
}
except (ValueError, TypeError):
return None
def _safe_float(value) -> Optional[float]:
"""Convert to float, return None if empty or invalid."""
if value is None or str(value).strip() == "":
return None
try:
return float(value)
except (ValueError, TypeError):
return None
def _safe_int(value) -> Optional[int]:
"""Convert to int, return None if empty or invalid."""
if value is None or str(value).strip() == "":
return None
try:
return int(float(value))
except (ValueError, TypeError):
return None
#!/usr/bin/env python3
"""
Uptrend Analyzer - Report Generator
Generates JSON and Markdown reports for uptrend breadth analysis.
"""
import json
import sys
def generate_json_report(analysis: dict, output_file: str):
"""Save full analysis as JSON"""
try:
with open(output_file, "w") as f:
json.dump(analysis, f, indent=2, default=str)
except OSError as e:
print(f"ERROR: Failed to write JSON report to {output_file}: {e}", file=sys.stderr)
raise
print(f"JSON report saved to: {output_file}")
def generate_markdown_report(analysis: dict, output_file: str):
"""Generate comprehensive Markdown report"""
lines = []
composite = analysis.get("composite", {})
components = analysis.get("components", {})
metadata = analysis.get("metadata", {})
score = composite.get("composite_score", 0)
zone = composite.get("zone", "Unknown")
zone_detail = composite.get("zone_detail", zone)
exposure = composite.get("exposure_guidance", "N/A")
# Header
lines.append("# Uptrend Analyzer Report")
lines.append("")
lines.append(f"**Generated:** {metadata.get('generated_at', 'N/A')}")
lines.append("**Data Source:** Monty's Uptrend Ratio Dashboard (GitHub CSV)")
lines.append("**API Key Required:** No")
lines.append("")
# Overall Assessment
lines.append("---")
lines.append("")
lines.append("## Overall Assessment")
lines.append("")
zone_emoji = _zone_emoji(composite.get("zone_color", ""))
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| **Composite Score** | **{score}/100** |")
lines.append(f"| **Zone** | {zone_emoji} {zone} |")
lines.append(f"| **Zone Detail** | {zone_detail} |")
# Zone proximity
prox = composite.get("zone_proximity", {})
if prox.get("at_boundary"):
lines.append(f"| **Zone Proximity** | **{prox.get('label', '')}** |")
lines.append(f"| **Exposure Guidance** | {exposure} |")
# Warning penalty
warning_penalty = composite.get("warning_penalty", 0)
if warning_penalty != 0:
raw_score = composite.get("composite_score_raw", score)
lines.append(f"| **Warning Penalty** | {warning_penalty} (raw: {raw_score}/100) |")
# Active warnings summary
active_warnings = composite.get("active_warnings", [])
if active_warnings:
warning_labels = [w.get("label", "") for w in active_warnings]
lines.append(
f"| **Active Warnings** | {len(active_warnings)}: {', '.join(warning_labels)} |"
)
lines.append(
f"| **Strongest Component** | {composite.get('strongest_component', {}).get('label', 'N/A')} "
f"({composite.get('strongest_component', {}).get('score', 0)}/100) |"
)
lines.append(
f"| **Weakest Component** | {composite.get('weakest_component', {}).get('label', 'N/A')} "
f"({composite.get('weakest_component', {}).get('score', 0)}/100) |"
)
dq = composite.get("data_quality", {})
if dq:
lines.append(f"| **Data Quality** | {dq.get('label', 'N/A')} |")
# Historical confidence
historical = components.get("historical_context", {})
confidence = historical.get("confidence", {})
if confidence:
lines.append(
f"| **Confidence** | {confidence.get('confidence_level', 'N/A')} "
f"({confidence.get('sample_label', '')}, {confidence.get('regime_coverage', '')} regime coverage) |"
)
lines.append("")
# Guidance blockquote
lines.append(f"> **Guidance:** {composite.get('guidance', '')}")
# Bull + warning tension visualization
if active_warnings and zone in ("Strong Bull", "Bull"):
lines.append(">")
lines.append(
f"> Note: Score is in the {zone} zone, but {len(active_warnings)} warning(s) are active."
)
lines.append("> Exposure guidance has been tightened. See Active Warnings below.")
lines.append("")
# Active Warnings - promoted to independent H2 section, right after Overall Assessment
if active_warnings:
lines.append("---")
lines.append("")
lines.append("## Active Warnings")
lines.append("")
for warning in active_warnings:
lines.append(f"### {warning.get('label', 'WARNING')}")
lines.append(f"> {warning.get('description', '')}")
lines.append("")
for action in warning.get("actions", []):
lines.append(f"- {action}")
lines.append("")
# Current Market Snapshot
breadth = components.get("market_breadth", {})
if breadth.get("data_available"):
lines.append("---")
lines.append("")
lines.append("## Current Market Snapshot")
lines.append("")
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| Uptrend Ratio | {breadth.get('ratio_pct', 'N/A')}% |")
lines.append(f"| 10-Day MA | {breadth.get('ma_10_pct', 'N/A')}% |")
lines.append(f"| Trend | {breadth.get('trend', 'N/A')} |")
lines.append(f"| Slope | {_format_slope(breadth.get('slope'))} |")
lines.append(
f"| Distance from 37% (Overbought) | {_format_distance(breadth.get('distance_from_upper'))} |"
)
lines.append(
f"| Distance from 9.7% (Oversold) | {_format_distance(breadth.get('distance_from_lower'))} |"
)
lines.append(f"| Date | {breadth.get('date', 'N/A')} |")
lines.append("")
# Component Scores Table
lines.append("---")
lines.append("")
lines.append("## Component Scores")
lines.append("")
lines.append("| # | Component | Weight | Score | Contribution | Signal |")
lines.append("|---|-----------|--------|-------|--------------|--------|")
component_order = [
"market_breadth",
"sector_participation",
"sector_rotation",
"momentum",
"historical_context",
]
for i, key in enumerate(component_order, 1):
comp = composite.get("component_scores", {}).get(key, {})
detail = components.get(key, {})
signal = detail.get("signal", "N/A")
score_val = comp.get("score", 0)
weight_pct = f"{comp.get('weight', 0) * 100:.0f}%"
contribution = comp.get("weighted_contribution", 0)
bar = _score_bar(score_val)
lines.append(
f"| {i} | **{comp.get('label', key)}** | {weight_pct} | "
f"{bar} {score_val} | {contribution:.1f} | {signal} |"
)
lines.append("")
# Component Details
lines.append("---")
lines.append("")
lines.append("## Component Details")
lines.append("")
# 1. Market Breadth
lines.append("### 1. Market Breadth (Overall)")
lines.append("")
if breadth.get("data_available"):
lines.append(f"- **Uptrend Ratio:** {breadth.get('ratio_pct', 'N/A')}%")
lines.append(f"- **10-Day MA:** {breadth.get('ma_10_pct', 'N/A')}%")
lines.append(f"- **Trend:** {breadth.get('trend', 'N/A')}")
lines.append(f"- **Slope:** {_format_slope(breadth.get('slope'))}")
lines.append(f"- **Trend Adjustment:** {breadth.get('trend_adjustment', 0):+d}")
else:
lines.append("- Data unavailable")
lines.append("")
# 2. Sector Participation
participation = components.get("sector_participation", {})
lines.append("### 2. Sector Participation")
lines.append("")
if participation.get("data_available"):
lines.append(
f"- **Uptrending Sectors:** {participation.get('uptrend_count', 0)}"
f"/{participation.get('total_sectors', 0)}"
)
lines.append(f"- **Count Score:** {participation.get('count_score', 0)}/100")
lines.append(
f"- **Spread:** {participation.get('spread_pct', 'N/A')}% "
f"(score: {participation.get('spread_score', 0)}/100)"
)
lines.append(
f"- **Overbought ({'>'}37%):** {participation.get('overbought_count', 0)} "
f"sectors ({', '.join(participation.get('overbought_sectors', []))})"
)
lines.append(
f"- **Oversold ({'<'}9.7%):** {participation.get('oversold_count', 0)} "
f"sectors ({', '.join(participation.get('oversold_sectors', []))})"
)
else:
lines.append("- Data unavailable")
lines.append("")
# 3. Sector Rotation
rotation = components.get("sector_rotation", {})
lines.append("### 3. Sector Rotation")
lines.append("")
if rotation.get("data_available"):
lines.append(f"- **Cyclical Avg:** {rotation.get('cyclical_avg_pct', 'N/A')}%")
lines.append(f"- **Defensive Avg:** {rotation.get('defensive_avg_pct', 'N/A')}%")
lines.append(f"- **Commodity Avg:** {rotation.get('commodity_avg_pct', 'N/A')}%")
lines.append(f"- **Cyclical-Defensive Gap:** {rotation.get('difference_pct', 'N/A')}pp")
if rotation.get("late_cycle_flag"):
lines.append(
f"- **Late Cycle Warning:** YES (commodity penalty: {rotation.get('commodity_penalty', 0)})"
)
if rotation.get("divergence_flag"):
lines.append(
f"- **Divergence Warning:** YES (penalty: {rotation.get('divergence_penalty', 0)})"
)
for group_label, group_key in [
("Cyclical", "cyclical_divergence"),
("Defensive", "defensive_divergence"),
]:
div = rotation.get(group_key, {})
if div and div.get("flagged"):
lines.append(
f" - **{group_label} Divergence:** "
f"std={div.get('std_dev', 'N/A')}, spread={div.get('spread', 'N/A')}"
)
for outlier in div.get("outliers", []):
lines.append(
f" - Outlier: {outlier['sector']} "
f"(deviation: {outlier['deviation']:+.4f})"
)
for dissenter in div.get("trend_dissenters", []):
lines.append(
f" - Trend dissenter: {dissenter['sector']} "
f"({dissenter['trend']} vs majority {dissenter['majority']})"
)
# Group detail tables
for group_name, group_key in [
("Cyclical", "cyclical_details"),
("Defensive", "defensive_details"),
("Commodity", "commodity_details"),
]:
details = rotation.get(group_key, [])
if details:
lines.append(f"\n**{group_name} Sectors:**")
lines.append("")
lines.append("| Sector | Ratio | Trend | Slope |")
lines.append("|--------|-------|-------|-------|")
for d in details:
lines.append(
f"| {d.get('sector', '')} | "
f"{d.get('ratio_pct', 'N/A')}% | "
f"{d.get('trend', 'N/A')} | "
f"{_format_slope(d.get('slope'))} |"
)
lines.append("")
else:
lines.append("- Data unavailable")
lines.append("")
# 4. Momentum
momentum = components.get("momentum", {})
lines.append("### 4. Momentum")
lines.append("")
if momentum.get("data_available"):
lines.append(f"- **Raw Slope:** {_format_slope(momentum.get('slope'))} ")
lines.append(
f"- **Smoothed Slope ({momentum.get('slope_smoothing', 'EMA(3)')}):** "
f"{_format_slope(momentum.get('slope_smoothed'))} "
f"(score: {momentum.get('slope_score', 0)}/100)"
)
lines.append(
f"- **Acceleration ({momentum.get('acceleration_window', '10v10')}):** "
f"{momentum.get('acceleration', 'N/A')} "
f"({momentum.get('acceleration_label', 'N/A')}, "
f"score: {momentum.get('acceleration_score', 0)}/100)"
)
lines.append(
f"- **Sector Slope Breadth:** "
f"{momentum.get('sector_positive_slope_count', 0)}"
f"/{momentum.get('sector_total', 0)} positive "
f"(score: {momentum.get('sector_slope_breadth_score', 0)}/100)"
)
else:
lines.append("- Data unavailable")
lines.append("")
# 5. Historical Context
lines.append("### 5. Historical Context")
lines.append("")
if historical.get("data_available"):
lines.append(f"- **Current Ratio:** {historical.get('current_ratio_pct', 'N/A')}%")
lines.append(f"- **Percentile Rank:** {historical.get('percentile', 'N/A')}th")
lines.append(
f"- **Historical Range:** "
f"{historical.get('historical_min_pct', 'N/A')}% - "
f"{historical.get('historical_max_pct', 'N/A')}%"
)
lines.append(f"- **Historical Median:** {historical.get('historical_median_pct', 'N/A')}%")
lines.append(f"- **30-Day Avg:** {historical.get('avg_30d_pct', 'N/A')}%")
lines.append(f"- **90-Day Avg:** {historical.get('avg_90d_pct', 'N/A')}%")
lines.append(
f"- **Data Points:** {historical.get('data_points', 0)} "
f"({historical.get('date_range', 'N/A')})"
)
if confidence:
lines.append(
f"- **Confidence:** {confidence.get('confidence_level', 'N/A')} "
f"(sample: {confidence.get('sample_label', '')}, "
f"regime: {confidence.get('regime_coverage', '')}, "
f"recency: {confidence.get('recency_label', '')})"
)
else:
lines.append("- Data unavailable")
lines.append("")
# Sector Heatmap Table
sector_details = components.get("sector_participation", {}).get("sector_details", [])
if sector_details:
lines.append("---")
lines.append("")
lines.append("## Sector Heatmap")
lines.append("")
lines.append("| Rank | Sector | Ratio | Count/Total | 10MA | Trend | Slope | Status |")
lines.append("|------|--------|-------|-------------|------|-------|-------|--------|")
for i, s in enumerate(sector_details, 1):
count_val = s.get("count")
total_val = s.get("total")
if count_val is not None and total_val is not None:
count_total_str = f"{count_val}/{total_val}"
else:
count_total_str = "N/A"
lines.append(
f"| {i} | {s.get('sector', '')} | "
f"{s.get('ratio_pct', 'N/A')}% | "
f"{count_total_str} | "
f"{round(s['ma_10'] * 100, 1) if s.get('ma_10') is not None else 'N/A'}% | "
f"{s.get('trend', '')} | "
f"{_format_slope(s.get('slope'))} | "
f"{s.get('status', '')} |"
)
lines.append("")
# Divergent Status-Trend note
divergent = [
s
for s in sector_details
if (s.get("status") == "Overbought" and s.get("trend", "").lower() == "down")
or (s.get("status") == "Oversold" and s.get("trend", "").lower() == "up")
]
if divergent:
lines.append("> **Note on Status vs Trend:**")
lines.append(
"> Status (Overbought/Normal/Oversold) reflects the ratio *level* relative to thresholds."
)
lines.append("> Trend (Up/Down) reflects the *direction* of the 10-day MA slope.")
lines.append("> These can diverge:")
lines.append(
"> - **Overbought + Down** = high level but momentum rolling over (warning)"
)
lines.append(
"> - **Oversold + Up** = low level but momentum improving (potential recovery)"
)
for s in divergent:
lines.append(
f"> - **{s['sector']}**: {s['status']} ({s['ratio_pct']}%) / Trend {s['trend']}"
)
lines.append("")
# Recommended Actions
lines.append("---")
lines.append("")
lines.append("## Recommended Actions")
lines.append("")
lines.append(f"**Zone:** {zone} ({zone_detail})")
lines.append(f"**Exposure Guidance:** {exposure}")
lines.append("")
for action in composite.get("actions", []):
lines.append(f"- {action}")
lines.append("")
# Methodology
lines.append("---")
lines.append("")
lines.append("## Methodology")
lines.append("")
lines.append(
"This analysis uses Monty's Uptrend Ratio Dashboard data to assess market breadth health."
)
lines.append(
"The dashboard tracks ~2,800 US stocks across 11 sectors, measuring the percentage in uptrends."
)
lines.append("")
lines.append("**5-Component Scoring System (0-100, higher = healthier):**")
lines.append("")
lines.append("1. **Market Breadth (30%):** Overall uptrend ratio level and trend direction")
lines.append(
"2. **Sector Participation (25%):** Number of uptrending sectors and spread uniformity"
)
lines.append("3. **Sector Rotation (15%):** Cyclical vs Defensive vs Commodity balance")
lines.append("4. **Momentum (20%):** Slope direction, acceleration, and sector slope breadth")
lines.append("5. **Historical Context (10%):** Percentile rank in historical distribution")
lines.append("")
lines.append("**Key Thresholds (Monty's Dashboard):** Overbought = 37%, Oversold = 9.7%")
lines.append("")
lines.append("For detailed methodology, see `references/uptrend_methodology.md`.")
lines.append("")
# Disclaimer
lines.append("---")
lines.append("")
lines.append(
"**Disclaimer:** This analysis is for educational and informational purposes only. "
"Not investment advice. Past patterns may not predict future outcomes. "
"Conduct your own research and consult a financial advisor before making "
"investment decisions."
)
lines.append("")
try:
with open(output_file, "w") as f:
f.write("\n".join(lines))
except OSError as e:
print(f"ERROR: Failed to write Markdown report to {output_file}: {e}", file=sys.stderr)
raise
print(f"Markdown report saved to: {output_file}")
def _zone_emoji(color: str) -> str:
mapping = {
"green": "\U0001f7e2",
"light_green": "\U0001f7e2",
"yellow": "\U0001f7e1",
"orange": "\U0001f7e0",
"red": "\U0001f534",
}
return mapping.get(color, "\u26aa")
def _score_bar(score: int) -> str:
"""Simple text bar for score visualization"""
if score >= 80:
return "\u2588\u2588\u2588\u2588"
elif score >= 60:
return "\u2588\u2588\u2588\u2591"
elif score >= 40:
return "\u2588\u2588\u2591\u2591"
elif score >= 20:
return "\u2588\u2591\u2591\u2591"
else:
return "\u2591\u2591\u2591\u2591"
def _format_slope(value) -> str:
"""Format slope value with 4 decimal places."""
if value is None:
return "N/A"
return f"{value:+.4f}"
def _format_distance(value) -> str:
"""Format distance value as percentage points."""
if value is None:
return "N/A"
pct = round(value * 100, 1)
return f"{pct:+.1f}pp"
#!/usr/bin/env python3
"""
Uptrend Analyzer - Composite Scoring Engine
Combines 5 component scores into a weighted composite (0-100).
Higher score = healthier market (opposite of Market Top Detector).
Component Weights:
1. Market Breadth (Overall): 30%
2. Sector Participation: 25%
3. Sector Rotation: 15%
4. Momentum: 20%
5. Historical Context: 10%
Total: 100%
Scoring Zones (higher = better):
80-100: Strong Bull - Full Exposure (100%)
60-79: Bull - Normal Exposure (80-100%)
40-59: Neutral - Reduced Exposure (60-80%)
20-39: Cautious - Defensive (30-60%)
0-19: Bear - Capital Preservation (0-30%)
Zone Detail (7-level refinement):
80-100: Strong Bull
70-79: Bull-Upper
60-69: Bull-Lower
40-59: Neutral
30-39: Cautious-Upper
20-29: Cautious-Lower
0-19: Bear
"""
from typing import Optional
COMPONENT_WEIGHTS = {
"market_breadth": 0.30,
"sector_participation": 0.25,
"sector_rotation": 0.15,
"momentum": 0.20,
"historical_context": 0.10,
}
COMPONENT_LABELS = {
"market_breadth": "Market Breadth (Overall)",
"sector_participation": "Sector Participation",
"sector_rotation": "Sector Rotation",
"momentum": "Momentum",
"historical_context": "Historical Context",
}
# Warning penalty configuration
WARNING_PENALTIES = {
"late_cycle": -5,
"high_spread": -3,
"divergence": -3,
}
MULTI_WARNING_DISCOUNT = 1 # Reduce total penalty by this when multiple warnings
def calculate_composite_score(
component_scores: dict[str, float],
data_availability: Optional[dict[str, bool]] = None,
warning_flags: Optional[dict[str, bool]] = None,
historical_data_points: Optional[int] = None,
) -> dict:
"""
Calculate weighted composite market health score.
Args:
component_scores: Dict with keys matching COMPONENT_WEIGHTS,
each value 0-100
data_availability: Optional dict mapping component key -> bool indicating
if data was actually available (vs neutral default)
warning_flags: Optional dict of component warning flags, e.g.
{"late_cycle": True, "high_spread": True, "divergence": True}
historical_data_points: Optional number of historical data points for
confidence assessment
Returns:
Dict with composite_score, zone, zone_detail, exposure_guidance,
guidance, strongest/weakest components, component breakdown,
data_quality, active_warnings, zone_proximity, warning_penalty
"""
if data_availability is None:
data_availability = {}
if warning_flags is None:
warning_flags = {}
# Calculate weighted composite (raw)
composite_raw = 0.0
for key, weight in COMPONENT_WEIGHTS.items():
score = component_scores.get(key, 0)
composite_raw += score * weight
composite_raw = round(composite_raw, 1)
# Calculate warning penalties
penalty_result = _calculate_warning_penalties(warning_flags)
warning_penalty = penalty_result["total_penalty"]
# Apply penalty
composite = round(min(100, max(0, composite_raw + warning_penalty)), 1)
# Identify strongest and weakest components
valid_scores = {k: v for k, v in component_scores.items() if k in COMPONENT_WEIGHTS}
if valid_scores:
strongest = max(valid_scores, key=valid_scores.get)
weakest = min(valid_scores, key=valid_scores.get)
else:
strongest = "N/A"
weakest = "N/A"
# Get zone interpretation (uses penalized composite)
zone_info = _interpret_zone(composite)
# Get zone detail (7-level)
zone_detail = _interpret_zone_detail(composite)
# Get zone proximity
zone_proximity = _calculate_zone_proximity(composite)
# Overlay warning-specific adjustments
active_warnings, zone_info = _apply_warning_overlays(zone_info, warning_flags)
# Calculate data quality
available_count = sum(1 for k in COMPONENT_WEIGHTS if data_availability.get(k, True))
total_components = len(COMPONENT_WEIGHTS)
missing_components = [
COMPONENT_LABELS[k] for k in COMPONENT_WEIGHTS if not data_availability.get(k, True)
]
if available_count == total_components:
quality_label = f"Complete ({available_count}/{total_components} components)"
elif available_count >= total_components - 1:
quality_label = (
f"Partial ({available_count}/{total_components} components) - interpret with caution"
)
else:
quality_label = (
f"Limited ({available_count}/{total_components} components) - low confidence"
)
data_quality = {
"available_count": available_count,
"total_components": total_components,
"label": quality_label,
"missing_components": missing_components,
}
return {
"composite_score": composite,
"composite_score_raw": composite_raw,
"warning_penalty": warning_penalty,
"warning_penalty_breakdown": penalty_result["breakdown"],
"zone": zone_info["zone"],
"zone_detail": zone_detail,
"zone_color": zone_info["color"],
"zone_proximity": zone_proximity,
"exposure_guidance": zone_info["exposure_guidance"],
"guidance": zone_info["guidance"],
"actions": zone_info["actions"],
"active_warnings": active_warnings,
"strongest_component": {
"component": strongest,
"label": COMPONENT_LABELS.get(strongest, strongest),
"score": valid_scores.get(strongest, 0),
},
"weakest_component": {
"component": weakest,
"label": COMPONENT_LABELS.get(weakest, weakest),
"score": valid_scores.get(weakest, 0),
},
"data_quality": data_quality,
"component_scores": {
k: {
"score": component_scores.get(k, 0),
"weight": w,
"weighted_contribution": round(component_scores.get(k, 0) * w, 1),
"label": COMPONENT_LABELS[k],
}
for k, w in COMPONENT_WEIGHTS.items()
},
}
def _calculate_warning_penalties(warning_flags: dict[str, bool]) -> dict:
"""Calculate composite score penalties from active warnings.
Returns:
Dict with total_penalty (negative number or 0) and breakdown list
"""
breakdown = []
total = 0
for flag, penalty in WARNING_PENALTIES.items():
if warning_flags.get(flag):
breakdown.append({"flag": flag, "penalty": penalty})
total += penalty
# Multi-warning discount: reduce severity when multiple warnings fire
if len(breakdown) >= 2:
total += MULTI_WARNING_DISCOUNT
breakdown.append({"flag": "multi_warning_discount", "penalty": MULTI_WARNING_DISCOUNT})
return {
"total_penalty": total,
"breakdown": breakdown,
}
def _interpret_zone_detail(composite: float) -> str:
"""Map composite score to 7-level zone detail."""
if composite >= 80:
return "Strong Bull"
elif composite >= 70:
return "Bull-Upper"
elif composite >= 60:
return "Bull-Lower"
elif composite >= 40:
return "Neutral"
elif composite >= 30:
return "Cautious-Upper"
elif composite >= 20:
return "Cautious-Lower"
else:
return "Bear"
def _calculate_zone_proximity(composite: float) -> dict:
"""Calculate proximity to nearest zone boundary.
Boundaries are at 20, 40, 60, 80.
If within 10 points of a boundary, at_boundary=True.
"""
boundaries = [20, 40, 60, 80]
nearest = min(boundaries, key=lambda b: abs(composite - b))
distance = composite - nearest
at_boundary = abs(distance) <= 10
if distance > 0:
direction = "above"
elif distance < 0:
direction = "below"
else:
direction = "at"
label = (
f"Near boundary: {distance:+.1f} points from {nearest} ({direction})" if at_boundary else ""
)
return {
"nearest_boundary": nearest,
"distance": round(distance, 1),
"at_boundary": at_boundary,
"label": label,
}
def _apply_warning_overlays(zone_info: dict, warning_flags: dict[str, bool]) -> tuple:
"""Apply warning-driven adjustments to zone guidance and actions.
When component-level warnings (e.g. late_cycle, high_spread) are active,
tighten the exposure guidance and prepend cautionary actions even if the
composite score places the market in a bullish zone.
Returns:
Tuple of (active_warnings, zone_info) where zone_info is a new dict
if modifications were needed (original is never mutated).
"""
active = []
if warning_flags.get("late_cycle"):
active.append(
{
"flag": "late_cycle",
"label": "LATE CYCLE WARNING",
"description": (
"Commodity sectors leading both cyclical and defensive groups. "
"Historically associated with late-cycle inflation or sector rotation "
"preceding broader market weakness."
),
"actions": [
"Favor lower end of exposure range (e.g. 80% if guidance is 80-100%)",
"New entries limited to A-grade setups only",
"Tighten stops on commodity/cyclical positions",
"Monitor for commodity rollover as potential broad market lead indicator",
],
}
)
if warning_flags.get("high_spread"):
active.append(
{
"flag": "high_spread",
"label": "HIGH SELECTIVITY WARNING",
"description": (
"Wide spread between strongest and weakest sectors indicates "
"highly selective market. Breadth may be masking narrowing leadership."
),
"actions": [
"Concentrate on sectors with ratio above 10MA",
"Avoid lagging sectors even if trend is nominally 'up'",
"Reduce position count to highest-conviction ideas",
],
}
)
if warning_flags.get("divergence"):
active.append(
{
"flag": "divergence",
"label": "SECTOR DIVERGENCE WARNING",
"description": (
"Significant divergence detected within sector groups. "
"Some sectors within the same group are moving in opposite "
"directions, suggesting hidden risk beneath the averages."
),
"actions": [
"Verify individual sector trends before entering positions",
"Avoid sectors diverging from their group majority",
"Monitor for group convergence or further deterioration",
],
}
)
# If any warning is active, tighten exposure guidance for bullish zones
if active and zone_info["zone"] in ("Strong Bull", "Bull"):
zone_info = dict(zone_info) # shallow copy before mutation
if zone_info["zone"] == "Strong Bull":
zone_info["exposure_guidance"] = "Full Exposure with Caution (90-100%)"
elif zone_info["zone"] == "Bull":
zone_info["exposure_guidance"] = "Normal Exposure, Lower End (80-90%)"
zone_info["guidance"] += (
" However, active warnings suggest operating at the conservative end of the range."
)
return active, zone_info
def _interpret_zone(composite: float) -> dict:
"""Map composite score to health zone (higher = healthier)"""
if composite >= 80:
return {
"zone": "Strong Bull",
"color": "green",
"exposure_guidance": "Full Exposure (100%)",
"guidance": "Broad market participation with strong momentum. Ideal environment for new positions.",
"actions": [
"Full equity exposure allowed",
"Aggressive position sizing for breakout entries",
"Add to winning positions on pullbacks",
"Minimal hedging needed",
],
}
elif composite >= 60:
return {
"zone": "Bull",
"color": "light_green",
"exposure_guidance": "Normal Exposure (80-100%)",
"guidance": "Healthy market breadth supporting equity allocation. Standard position management.",
"actions": [
"Normal position sizing",
"New entries on quality setups",
"Standard stop-loss levels",
"Monitor sector rotation for early warnings",
],
}
elif composite >= 40:
return {
"zone": "Neutral",
"color": "yellow",
"exposure_guidance": "Reduced Exposure (60-80%)",
"guidance": "Mixed signals. Participate selectively with tighter risk controls.",
"actions": [
"Reduce position sizes by 20-30%",
"Focus on strongest sectors only",
"Tighten stop-losses",
"Avoid low-quality setups",
"Increase cash allocation gradually",
],
}
elif composite >= 20:
return {
"zone": "Cautious",
"color": "orange",
"exposure_guidance": "Defensive (30-60%)",
"guidance": "Weak breadth environment. Prioritize capital preservation over gains.",
"actions": [
"Significant cash allocation (40-70%)",
"Only hold strongest leaders in uptrending sectors",
"Tight stops on all positions",
"Consider defensive sector allocation",
"No new aggressive entries",
],
}
else:
return {
"zone": "Bear",
"color": "red",
"exposure_guidance": "Capital Preservation (0-30%)",
"guidance": "Severe breadth deterioration. Maximum defensive posture.",
"actions": [
"Maximum cash (70-100%)",
"Exit most equity positions",
"Only ultra-high-conviction holdings",
"Consider hedges (inverse ETFs, puts)",
"Wait for breadth recovery before re-entry",
],
}
"""Shared fixtures for Uptrend Analyzer 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 Uptrend Analyzer tests"""
def make_timeseries_row(
ratio=0.30,
ma_10=0.28,
slope=0.002,
trend="up",
worksheet="all",
date="2026-01-15",
count=840,
total=2800,
):
"""Create a timeseries data row."""
return {
"worksheet": worksheet,
"date": date,
"count": count,
"total": total,
"ratio": ratio,
"ma_10": ma_10,
"slope": slope,
"trend": trend,
}
def make_sector_summary_row(
sector="Technology", ratio=0.30, ma_10=0.28, trend="Up", slope=0.002, status="Normal"
):
"""Create a sector summary row."""
return {
"Sector": sector,
"Ratio": ratio,
"10MA": ma_10,
"Trend": trend,
"Slope": slope,
"Status": status,
}
def make_full_sector_summary(ratios=None, trends=None):
"""Create a full 11-sector summary.
Args:
ratios: Optional dict of sector -> ratio overrides
trends: Optional dict of sector -> trend overrides
"""
sectors = [
"Technology",
"Consumer Cyclical",
"Communication Services",
"Financial",
"Industrials",
"Utilities",
"Consumer Defensive",
"Healthcare",
"Real Estate",
"Energy",
"Basic Materials",
]
default_ratios = {
"Technology": 0.35,
"Consumer Cyclical": 0.30,
"Communication Services": 0.28,
"Financial": 0.27,
"Industrials": 0.32,
"Utilities": 0.20,
"Consumer Defensive": 0.22,
"Healthcare": 0.25,
"Real Estate": 0.18,
"Energy": 0.24,
"Basic Materials": 0.26,
}
if ratios:
default_ratios.update(ratios)
default_trends = {s: "Up" for s in sectors}
if trends:
default_trends.update(trends)
rows = []
for s in sectors:
r = default_ratios.get(s, 0.25)
t = default_trends.get(s, "Up")
slope = 0.002 if t == "Up" else -0.002
status = "Overbought" if r > 0.37 else "Oversold" if r < 0.097 else "Normal"
rows.append(
make_sector_summary_row(
sector=s,
ratio=r,
ma_10=r - 0.01,
trend=t,
slope=slope,
status=status,
)
)
return rows
def make_all_timeseries(n=20, base_ratio=0.30, slope=0.001):
"""Create a list of 'all' timeseries rows with increasing ratios.
Returns list sorted by date ascending (oldest first).
"""
rows = []
for i in range(n):
ratio = base_ratio + slope * i
ratio = max(0, min(1.0, ratio))
ma_10 = ratio - 0.005
rows.append(
make_timeseries_row(
ratio=round(ratio, 4),
ma_10=round(ma_10, 4),
slope=round(slope, 6),
trend="up" if slope > 0 else "down",
date=f"2026-01-{i + 1:02d}",
)
)
return rows
"""Tests for data_fetcher helper functions and UptrendDataFetcher (mocked HTTP)."""
from unittest.mock import MagicMock, patch
import requests
from data_fetcher import (
UptrendDataFetcher,
_parse_sector_summary_row,
_parse_timeseries_row,
_safe_float,
_safe_int,
build_summary_from_timeseries,
)
# --- _safe_float ---
def test_safe_float_normal():
assert _safe_float("0.123") == 0.123
def test_safe_float_int():
assert _safe_float("42") == 42.0
def test_safe_float_empty():
assert _safe_float("") is None
def test_safe_float_none():
assert _safe_float(None) is None
def test_safe_float_invalid():
assert _safe_float("abc") is None
# --- _safe_int ---
def test_safe_int_normal():
assert _safe_int("42") == 42
def test_safe_int_float_input():
assert _safe_int("42.7") == 42
def test_safe_int_empty():
assert _safe_int("") is None
def test_safe_int_none():
assert _safe_int(None) is None
def test_safe_int_invalid():
assert _safe_int("xyz") is None
# --- _parse_timeseries_row ---
def test_parse_timeseries_row_valid():
row = {
"worksheet": "sec_technology",
"date": "2026-01-15",
"count": "120",
"total": "400",
"ratio": "0.300",
"ma_10": "0.280",
"slope": "0.0015",
"trend": "up",
}
result = _parse_timeseries_row(row)
assert result is not None
assert result["worksheet"] == "sec_technology"
assert result["date"] == "2026-01-15"
assert result["count"] == 120
assert result["total"] == 400
assert result["ratio"] == 0.300
assert result["ma_10"] == 0.280
assert result["slope"] == 0.0015
assert result["trend"] == "up"
def test_parse_timeseries_row_missing():
row = {
"worksheet": "all",
"date": "2026-02-01",
"count": "",
"total": "",
"ratio": "",
"ma_10": "",
"slope": "",
"trend": "",
}
result = _parse_timeseries_row(row)
assert result is not None
assert result["worksheet"] == "all"
assert result["date"] == "2026-02-01"
assert result["count"] is None
assert result["total"] is None
assert result["ratio"] is None
assert result["ma_10"] is None
assert result["slope"] is None
assert result["trend"] == ""
# --- _parse_sector_summary_row ---
def test_parse_sector_summary_row_valid():
row = {
"Sector": "Technology",
"Ratio": "0.288",
"10MA": "0.266",
"Trend": "Up",
"Slope": "0.0020",
"Status": "Normal",
}
result = _parse_sector_summary_row(row)
assert result is not None
assert result["Sector"] == "Technology"
assert result["Ratio"] == 0.288
assert result["10MA"] == 0.266
assert result["Trend"] == "Up"
assert result["Slope"] == 0.0020
assert result["Status"] == "Normal"
# --- build_summary_from_timeseries ---
def test_build_summary_overbought_oversold():
sector_ts = {
"sec_technology": {
"ratio": 0.40,
"ma_10": 0.38,
"slope": 0.002,
"trend": "up",
},
"sec_utilities": {
"ratio": 0.05,
"ma_10": 0.06,
"slope": -0.001,
"trend": "down",
},
}
result = build_summary_from_timeseries(sector_ts)
assert len(result) == 2
by_sector = {r["Sector"]: r for r in result}
tech = by_sector["Technology"]
assert tech["Ratio"] == 0.40
assert tech["10MA"] == 0.38
assert tech["Slope"] == 0.002
assert tech["Trend"] == "Up"
assert tech["Status"] == "Overbought"
util = by_sector["Utilities"]
assert util["Ratio"] == 0.05
assert util["10MA"] == 0.06
assert util["Slope"] == -0.001
assert util["Trend"] == "Down"
assert util["Status"] == "Oversold"
# --- UptrendDataFetcher (mocked HTTP) ---
SAMPLE_CSV = (
"worksheet,date,count,total,ratio,ma_10,slope,trend\n"
"all,2026-01-15,840,2800,0.300,0.280,0.0015,up\n"
"sec_technology,2026-01-15,120,400,0.300,0.280,0.0020,up\n"
)
class TestUptrendDataFetcherMocked:
def _make_mock_response(self, text, status_code=200):
mock_resp = MagicMock()
mock_resp.text = text
mock_resp.status_code = status_code
mock_resp.raise_for_status = MagicMock()
return mock_resp
def test_successful_fetch_returns_parsed_rows(self):
fetcher = UptrendDataFetcher()
mock_resp = self._make_mock_response(SAMPLE_CSV)
with patch.object(fetcher.session, "get", return_value=mock_resp):
rows = fetcher.fetch_timeseries()
assert len(rows) == 2
assert rows[0]["worksheet"] == "all"
assert rows[0]["ratio"] == 0.300
assert rows[1]["worksheet"] == "sec_technology"
def test_http_404_returns_empty_list(self):
fetcher = UptrendDataFetcher()
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError("404")
with patch.object(fetcher.session, "get", return_value=mock_resp):
rows = fetcher.fetch_timeseries()
assert rows == []
def test_timeout_returns_empty_list(self):
fetcher = UptrendDataFetcher()
with patch.object(
fetcher.session,
"get",
side_effect=requests.exceptions.Timeout("timeout"),
):
rows = fetcher.fetch_timeseries()
assert rows == []
def test_malformed_csv_no_exception(self):
fetcher = UptrendDataFetcher()
malformed = "not,a,valid,csv\nwith,bad,data,here\n"
mock_resp = self._make_mock_response(malformed)
with patch.object(fetcher.session, "get", return_value=mock_resp):
rows = fetcher.fetch_timeseries()
# Should not raise; rows may be empty or partial
assert isinstance(rows, list)
def test_caching_prevents_second_http_call(self):
fetcher = UptrendDataFetcher()
mock_resp = self._make_mock_response(SAMPLE_CSV)
with patch.object(fetcher.session, "get", return_value=mock_resp) as mock_get:
fetcher.fetch_timeseries()
fetcher.fetch_timeseries()
assert mock_get.call_count == 1
# --- build_summary_from_timeseries edge cases ---
def test_build_summary_empty_dict():
"""Empty input returns empty list."""
result = build_summary_from_timeseries({})
assert result == []
def test_build_summary_none_ratio():
"""None ratio maps to 'Normal' status."""
sector_ts = {"sec_technology": {"ratio": None, "ma_10": 0.28, "slope": 0.002, "trend": "up"}}
result = build_summary_from_timeseries(sector_ts)
assert result[0]["Ratio"] is None
assert result[0]["Status"] == "Normal"
def test_build_summary_unknown_worksheet():
"""Unknown worksheet key falls back to raw name."""
sector_ts = {"custom_ws": {"ratio": 0.25, "ma_10": 0.24, "slope": 0.001, "trend": "up"}}
result = build_summary_from_timeseries(sector_ts)
assert result[0]["Sector"] == "custom_ws"
"""Tests for historical_context_calculator.py"""
import pytest
from calculators.historical_context_calculator import (
_avg_last_n,
calculate_historical_context,
)
from helpers import make_timeseries_row
def _make_ts(ratios):
"""Create timeseries from ratio list."""
return [make_timeseries_row(ratio=r, date=f"2026-01-{i + 1:02d}") for i, r in enumerate(ratios)]
# --- _avg_last_n tests ---
def test_avg_last_n_normal():
"""10 values, n=5 -> average of last 5."""
values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
result = _avg_last_n(values, 5)
assert result == pytest.approx(8.0) # (6+7+8+9+10)/5
def test_avg_last_n_fewer():
"""3 values, n=10 -> average of all 3."""
values = [10, 20, 30]
result = _avg_last_n(values, 10)
assert result == pytest.approx(20.0) # (10+20+30)/3
def test_avg_last_n_empty():
"""Empty list -> None."""
result = _avg_last_n([], 5)
assert result is None
# --- Percentile rank tests ---
def test_percentile_lowest():
"""Current value is the lowest -> percentile near 0."""
# 10 values from 0.10 to 0.19 ascending; current (last) is 0.05
ratios = [0.10, 0.11, 0.12, 0.13, 0.14, 0.15, 0.16, 0.17, 0.18, 0.05]
result = calculate_historical_context(_make_ts(ratios))
# 0.05 is below all others; below_count=0, equal_count=1
# percentile = (0 + 1*0.5)/10 * 100 = 5.0
assert result["percentile"] == pytest.approx(5.0, abs=0.1)
assert result["score"] <= 10
def test_percentile_highest():
"""Current value is the highest -> percentile near 100."""
ratios = [0.10, 0.11, 0.12, 0.13, 0.14, 0.15, 0.16, 0.17, 0.18, 0.50]
result = calculate_historical_context(_make_ts(ratios))
# 0.50 is above all others; below_count=9, equal_count=1
# percentile = (9 + 1*0.5)/10 * 100 = 95.0
assert result["percentile"] == pytest.approx(95.0, abs=0.1)
assert result["score"] >= 90
def test_percentile_median():
"""Current value is in the middle -> percentile near 50."""
ratios = [0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90, 0.50]
result = calculate_historical_context(_make_ts(ratios))
# current=0.50; below_count=4 (0.10,0.20,0.30,0.40); equal_count=2 (two 0.50s)
# percentile = (4 + 2*0.5)/10 * 100 = 50.0
assert result["percentile"] == pytest.approx(50.0, abs=0.1)
def test_percentile_with_duplicates():
"""Duplicates are handled correctly in percentile calculation."""
ratios = [0.30, 0.30, 0.30, 0.30, 0.30]
result = calculate_historical_context(_make_ts(ratios))
# All equal: below_count=0, equal_count=5
# percentile = (0 + 5*0.5)/5 * 100 = 50.0
assert result["percentile"] == pytest.approx(50.0, abs=0.1)
assert result["score"] == 50
# --- Statistics tests ---
def test_stats_min_max():
"""Min and max are correctly calculated."""
ratios = [0.15, 0.45, 0.25, 0.35, 0.10, 0.50]
result = calculate_historical_context(_make_ts(ratios))
assert result["historical_min"] == pytest.approx(0.10, abs=0.0001)
assert result["historical_max"] == pytest.approx(0.50, abs=0.0001)
def test_stats_median_odd():
"""Odd count of ratios -> median is middle element."""
ratios = [0.10, 0.20, 0.30, 0.40, 0.50]
result = calculate_historical_context(_make_ts(ratios))
# sorted: [0.10, 0.20, 0.30, 0.40, 0.50], median = 0.30
assert result["historical_median"] == pytest.approx(0.30, abs=0.0001)
def test_stats_median_even():
"""Even count of ratios -> median is average of two middle elements."""
ratios = [0.10, 0.20, 0.40, 0.50]
result = calculate_historical_context(_make_ts(ratios))
# sorted: [0.10, 0.20, 0.40, 0.50], median = (0.20 + 0.40)/2 = 0.30
assert result["historical_median"] == pytest.approx(0.30, abs=0.0001)
# --- Full calculation test ---
def test_full_calculation():
"""Full flow with 20+ data points returns all expected fields."""
ratios = [0.10 + i * 0.02 for i in range(25)]
result = calculate_historical_context(_make_ts(ratios))
assert result["data_available"] is True
assert result["data_points"] == 25
assert 0 <= result["score"] <= 100
assert result["percentile"] is not None
assert result["current_ratio"] == pytest.approx(ratios[-1], abs=0.0001)
assert result["historical_min"] is not None
assert result["historical_max"] is not None
assert result["historical_median"] is not None
assert result["avg_30d"] is not None
assert result["avg_90d"] is not None
assert "signal" in result
assert "date_range" in result
# --- Edge case tests ---
def test_empty_timeseries():
"""Empty list -> score=50, data_available=False."""
result = calculate_historical_context([])
assert result["score"] == 50
assert result["data_available"] is False
assert result["percentile"] is None
assert result["current_ratio"] is None
def test_single_datapoint():
"""1 data point -> insufficient data."""
result = calculate_historical_context(_make_ts([0.30]))
assert result["score"] == 50
assert result["data_available"] is False
assert "INSUFFICIENT" in result["signal"]
assert result["data_points"] == 1
def test_two_datapoints():
"""Minimum 2 points needed for valid calculation."""
result = calculate_historical_context(_make_ts([0.20, 0.40]))
assert result["data_available"] is True
assert result["data_points"] == 2
assert 0 <= result["score"] <= 100
assert result["percentile"] is not None
"""Tests for market_breadth_calculator.py"""
import pytest
from calculators.market_breadth_calculator import _ratio_to_score, calculate_market_breadth
from helpers import make_all_timeseries, make_timeseries_row
# ---------------------------------------------------------------------------
# _ratio_to_score: Crisis band (< 0.097)
# ---------------------------------------------------------------------------
def test_ratio_to_score_crisis_zero():
"""ratio=0 maps to score=0."""
assert _ratio_to_score(0) == pytest.approx(0, abs=1)
def test_ratio_to_score_crisis_below_lower():
"""ratio=0.05 maps to ~4.6 (crisis band, linear 0-9)."""
assert _ratio_to_score(0.05) == pytest.approx(4.6, abs=1)
# ---------------------------------------------------------------------------
# _ratio_to_score: Weak band boundary (0.097)
# ---------------------------------------------------------------------------
def test_ratio_to_score_at_lower_threshold():
"""ratio=0.097 maps to 10 (bottom of weak band)."""
assert _ratio_to_score(0.097) == pytest.approx(10, abs=1)
# ---------------------------------------------------------------------------
# _ratio_to_score: Weak band (0.097-0.25)
# ---------------------------------------------------------------------------
def test_ratio_to_score_weak_midpoint():
"""ratio=0.17 maps to ~24 (mid-weak band)."""
assert _ratio_to_score(0.17) == pytest.approx(23.8, abs=1)
# ---------------------------------------------------------------------------
# _ratio_to_score: Neutral band (0.25-0.37)
# ---------------------------------------------------------------------------
def test_ratio_to_score_at_025():
"""ratio=0.25 maps to 40 (bottom of neutral band)."""
assert _ratio_to_score(0.25) == pytest.approx(40, abs=1)
def test_ratio_to_score_neutral_midpoint():
"""ratio=0.31 maps to ~54.5 (mid-neutral band)."""
assert _ratio_to_score(0.31) == pytest.approx(54.5, abs=1)
# ---------------------------------------------------------------------------
# _ratio_to_score: Bullish band (0.37-0.50)
# ---------------------------------------------------------------------------
def test_ratio_to_score_at_upper_threshold():
"""ratio=0.37 maps to 70 (bottom of bullish band)."""
assert _ratio_to_score(0.37) == pytest.approx(70, abs=1)
def test_ratio_to_score_bullish_midpoint():
"""ratio=0.43 maps to ~78.8 (mid-bullish band)."""
assert _ratio_to_score(0.43) == pytest.approx(78.8, abs=1)
# ---------------------------------------------------------------------------
# _ratio_to_score: Strong Bull band (>= 0.50)
# ---------------------------------------------------------------------------
def test_ratio_to_score_at_050():
"""ratio=0.50 maps to 90 (bottom of strong bull band)."""
assert _ratio_to_score(0.50) == pytest.approx(90, abs=1)
def test_ratio_to_score_strong_bull():
"""ratio=0.55 maps to 95."""
assert _ratio_to_score(0.55) == pytest.approx(95, abs=1)
def test_ratio_to_score_max_cap():
"""ratio=0.70 is capped at 100."""
assert _ratio_to_score(0.70) == pytest.approx(100, abs=1)
# ---------------------------------------------------------------------------
# Trend adjustment in calculate_market_breadth
# ---------------------------------------------------------------------------
def test_trend_adjustment_up():
"""trend='up' with positive slope adds +5."""
latest = make_timeseries_row(ratio=0.30, trend="up", slope=0.002)
ts = make_all_timeseries(n=5, base_ratio=0.30, slope=0.001)
result = calculate_market_breadth(latest, ts)
base = _ratio_to_score(0.30)
assert result["score"] == round(min(100, max(0, base + 5)))
assert result["trend_adjustment"] == 5
def test_trend_adjustment_down():
"""trend='down' with negative slope subtracts -5."""
latest = make_timeseries_row(ratio=0.30, trend="down", slope=-0.002)
ts = make_all_timeseries(n=5, base_ratio=0.30, slope=-0.001)
result = calculate_market_breadth(latest, ts)
base = _ratio_to_score(0.30)
assert result["score"] == round(min(100, max(0, base - 5)))
assert result["trend_adjustment"] == -5
def test_trend_adjustment_none_slope():
"""trend='up' but slope=None gives no adjustment."""
latest = make_timeseries_row(ratio=0.30, trend="up", slope=None)
ts = make_all_timeseries(n=5, base_ratio=0.30)
result = calculate_market_breadth(latest, ts)
assert result["trend_adjustment"] == 0
def test_trend_adjustment_mismatch():
"""trend='up' but negative slope gives no adjustment."""
latest = make_timeseries_row(ratio=0.30, trend="up", slope=-0.001)
ts = make_all_timeseries(n=5, base_ratio=0.30)
result = calculate_market_breadth(latest, ts)
assert result["trend_adjustment"] == 0
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_no_data_returns_neutral():
"""None input returns neutral score 50."""
result = calculate_market_breadth(None, [])
assert result["score"] == 50
assert result["data_available"] is False
def test_full_calculation_bull():
"""Full calculation: ratio=0.40 with uptrend -> ~79."""
latest = make_timeseries_row(ratio=0.40, trend="up", slope=0.003)
ts = make_all_timeseries(n=10, base_ratio=0.38, slope=0.002)
result = calculate_market_breadth(latest, ts)
# base = 70 + (0.40 - 0.37) / (0.50 - 0.37) * 19 ≈ 74.4, +5 = 79.4 -> 79
assert result["score"] == pytest.approx(79, abs=1)
assert result["data_available"] is True
assert result["trend"] == "up"
def test_score_clamped_0_100():
"""Score is clamped between 0 and 100 even with trend adjustment."""
# Very high ratio + up trend: should cap at 100
latest_high = make_timeseries_row(ratio=0.70, trend="up", slope=0.005)
ts = make_all_timeseries(n=5, base_ratio=0.65)
result_high = calculate_market_breadth(latest_high, ts)
assert result_high["score"] <= 100
# Very low ratio + down trend: should not go below 0
latest_low = make_timeseries_row(ratio=0.0, trend="down", slope=-0.005)
ts_low = make_all_timeseries(n=5, base_ratio=0.02, slope=-0.001)
result_low = calculate_market_breadth(latest_low, ts_low)
assert result_low["score"] >= 0
"""Tests for momentum calculator."""
import pytest
from calculators.momentum_calculator import (
_score_acceleration_from_values,
_score_sector_slope_breadth,
_score_slope,
calculate_momentum,
)
from helpers import make_sector_summary_row
# ── _score_slope ────────────────────────────────────────────────────
class TestScoreSlope:
def test_slope_strong_bullish(self):
# 0.025 -> 95 + (0.005/0.01)*5 = 97.5
assert _score_slope(0.025) == pytest.approx(97.5, abs=1)
def test_slope_bullish(self):
# 0.015 -> 75 + (0.005/0.01)*19 = 84.5
assert _score_slope(0.015) == pytest.approx(84.5, abs=1)
def test_slope_mild_positive(self):
# 0.005 -> 55 + (0.005/0.01)*19 = 64.5
assert _score_slope(0.005) == pytest.approx(64.5, abs=1)
def test_slope_zero(self):
# 0.0 -> 55
assert _score_slope(0.0) == pytest.approx(55.0, abs=1)
def test_slope_mild_negative(self):
# -0.005 -> 35 + (0.005/0.01)*19 = 44.5
assert _score_slope(-0.005) == pytest.approx(44.5, abs=1)
def test_slope_bearish(self):
# -0.015 -> 10 + (0.005/0.01)*24 = 22
assert _score_slope(-0.015) == pytest.approx(22.0, abs=1)
def test_slope_strong_bearish(self):
# -0.025 -> 9 + (-0.005/0.01)*9 = 4.5
assert _score_slope(-0.025) == pytest.approx(4.5, abs=1)
# ── _score_acceleration ────────────────────────────────────────────
class TestScoreAcceleration:
def _make_slope_list(self, prior_slope, recent_slope, window=5):
"""Build flat slope list with distinct prior and recent values."""
return [prior_slope] * window + [recent_slope] * window
def test_accel_strong_accelerating(self):
# recent_avg - prior_avg = 0.010 - 0.001 = 0.009 > 0.005
slopes = self._make_slope_list(prior_slope=0.001, recent_slope=0.010)
score, value, label = _score_acceleration_from_values(slopes, window=5)
assert score == 90
assert label == "strong_accelerating"
assert value == pytest.approx(0.009, abs=0.001)
def test_accel_accelerating(self):
# recent_avg - prior_avg = 0.005 - 0.002 = 0.003 > 0.001
slopes = self._make_slope_list(prior_slope=0.002, recent_slope=0.005)
score, value, label = _score_acceleration_from_values(slopes, window=5)
assert score == 75
assert label == "accelerating"
def test_accel_steady(self):
# recent_avg - prior_avg = 0.003 - 0.003 = 0.0
slopes = self._make_slope_list(prior_slope=0.003, recent_slope=0.003)
score, value, label = _score_acceleration_from_values(slopes, window=5)
assert score == 50
assert label == "steady"
def test_accel_decelerating(self):
# recent_avg - prior_avg = 0.002 - 0.005 = -0.003
slopes = self._make_slope_list(prior_slope=0.005, recent_slope=0.002)
score, value, label = _score_acceleration_from_values(slopes, window=5)
assert score == 25
assert label == "decelerating"
def test_accel_strong_decelerating(self):
# recent_avg - prior_avg = 0.001 - 0.010 = -0.009 < -0.005
slopes = self._make_slope_list(prior_slope=0.010, recent_slope=0.001)
score, value, label = _score_acceleration_from_values(slopes, window=5)
assert score == 10
assert label == "strong_decelerating"
def test_accel_insufficient_data(self):
slopes = [0.002] * 5 # Only 5 points, need 10 for window=5
score, value, label = _score_acceleration_from_values(slopes, window=5)
assert score == 50
assert value is None
assert label == "insufficient_data"
def test_accel_exact_boundary_window(self):
"""Exactly window*2 data points should calculate normally."""
slopes = [0.002] * 5 + [0.005] * 5
score, value, label = _score_acceleration_from_values(slopes, window=5)
assert label == "accelerating"
assert score == 75
# ── _score_sector_slope_breadth ─────────────────────────────────────
class TestSectorSlopeBreadth:
def test_sector_breadth_all_positive(self):
sectors = [
"Technology",
"Consumer Cyclical",
"Communication Services",
"Financial",
"Industrials",
"Utilities",
"Consumer Defensive",
"Healthcare",
"Real Estate",
"Energy",
"Basic Materials",
]
summary = [make_sector_summary_row(sector=s, slope=0.003) for s in sectors]
score, positive, total = _score_sector_slope_breadth(summary)
assert score == pytest.approx(100.0, abs=1)
assert positive == 11
assert total == 11
def test_sector_breadth_none_positive(self):
sectors = [
"Technology",
"Consumer Cyclical",
"Communication Services",
"Financial",
"Industrials",
"Utilities",
"Consumer Defensive",
"Healthcare",
"Real Estate",
"Energy",
"Basic Materials",
]
summary = [make_sector_summary_row(sector=s, slope=-0.003) for s in sectors]
score, positive, total = _score_sector_slope_breadth(summary)
assert score == pytest.approx(0.0, abs=1)
assert positive == 0
def test_sector_breadth_half(self):
summary = []
for i in range(5):
summary.append(make_sector_summary_row(sector=f"Pos{i}", slope=0.003))
for i in range(5):
summary.append(make_sector_summary_row(sector=f"Neg{i}", slope=-0.003))
score, positive, total = _score_sector_slope_breadth(summary)
assert score == pytest.approx(50.0, abs=1)
assert positive == 5
assert total == 10
# ── Full momentum calculation ──────────────────────────────────────
class TestFullMomentum:
def test_full_momentum_no_data(self):
result = calculate_momentum([], [])
assert result["score"] == 50
assert result["data_available"] is False
assert result["slope"] is None
Related skills
How it compares
Pick uptrend-analyzer when you need sector-level uptrend ratio gates backed by a published 2,800-stock dashboard dataset.
FAQ
How many stocks does the uptrend dashboard track?
Monty's Uptrend Ratio Dashboard tracks approximately 2,800 US stocks across 11 GICS sectors, publishing daily CSV uptrend classifications on GitHub.
What defines an uptrend stock in uptrend-analyzer?
uptrend-analyzer classifies a stock as uptrend when Finviz Elite screener conditions all pass, including price above $10 and average volume above 100,000 shares.
Where does uptrend-analyzer source its data?
uptrend-analyzer reads daily CSV exports from the tradermonty/uptrend-dashboard GitHub repository and references the live Streamlit dashboard at uptrend-dashboard.streamlit.app.
Is Uptrend Analyzer safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.