
Macro Regime Detector
- 1.2k installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
macro-regime-detector is a Claude trading skill that classifies current market conditions into one of five historical macro regimes using concentration, credit, and breadth signals before portfolio or trading decisions.
About
macro-regime-detector is a skill from tradermonty/claude-trading-skills that maps live macro readings into one of five historical regime classifications before portfolio or trading calls. Reference cases include documented periods such as 2023–2024 Magnificent Seven concentration, where RSP/SPY declined from ~0.32 to ~0.29 and top seven stocks drove roughly 60% of S&P 500 returns over ~18 months. The skill uses ETF ratio signals—RSP/SPY, IWM/SPY, HYG/LQD—and concentration characteristics to contextualize current conditions against historical parallels. Developers and quant engineers reach for macro-regime-detector when building or operating trading analytics that need a structured regime label before adjusting portfolio posture.
- Classifies markets into five regimes using RSP/SPY, IWM/SPY, credit spreads, and sector rotation signals
- Provides historical parallels with concrete examples from 2003–2024 including Magnificent Seven, post-COVID, and post-el
- Outputs regime label, confidence, and recommended portfolio posture
- Designed as a reusable context provider for trading agents and quant workflows
Macro Regime Detector by the numbers
- 1,189 all-time installs (skills.sh)
- +87 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #934 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill macro-regime-detectorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 2.6k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you classify current macro market regime?
Classify current market conditions into one of five historical macro regimes before making portfolio or trading decisions.
Who is it for?
Quant developers and portfolio engineers building regime-aware trading systems who need structured macro classification before allocation decisions.
Skip if: Individual stock pickers, long-only buy-and-hold investors with no regime framework, or non-financial application development.
When should I use this skill?
A developer or analyst needs to classify current market conditions into a macro regime before portfolio positioning or trading system logic runs.
What you get
Five-bucket macro regime label with historical parallel context, ETF ratio readings, and suggested portfolio posture guidance.
- Macro regime classification
- Historical parallel reference mapping
By the numbers
- Classifies markets into 5 historical macro regime buckets
- 2023–2024 reference: top 7 stocks drove ~60% of S&P 500 returns over ~18 months
- RSP/SPY declined from ~0.32 to ~0.29 during Magnificent Seven concentration regime
Files
Macro Regime Detector
Detect structural macro regime transitions using monthly-frequency cross-asset ratio analysis. This skill identifies 1-2 year regime shifts that inform strategic portfolio positioning.
When to Use
- User asks about current macro regime or regime transitions
- User wants to understand structural market rotations (concentration vs broadening)
- User asks about long-term positioning based on yield curve, credit, or cross-asset signals
- User references RSP/SPY ratio, IWM/SPY, HYG/LQD, or other cross-asset ratios
- User wants to assess whether a regime change is underway
Workflow
1. Load reference documents for methodology context:
references/regime_detection_methodology.mdreferences/indicator_interpretation_guide.md
2. Execute the main analysis script:
uv run python3 skills/macro-regime-detector/scripts/macro_regime_detector.py --output-dir reports/This fetches 600 days of data for 9 ETFs + Treasury rates (~10 API calls total). An FMP API key is required to run this skill (the client raises if it is missing). For individual ETFs whose FMP historical-price endpoint returns nothing, the client automatically falls back to yfinance — this fallback needs no additional API key, but it does not remove the FMP key requirement.
3. Read the generated Markdown report and present findings to user.
4. Provide additional context using references/historical_regimes.md when user asks about historical parallels.
Prerequisites
- FMP API Key (required): Set
FMP_API_KEYenvironment variable or pass--api-key - Free tier (250 calls/day) is sufficient (script uses ~10 calls)
6 Components
| # | Component | Ratio/Data | Weight | What It Detects |
|---|---|---|---|---|
| 1 | Market Concentration | RSP/SPY | 25% | Mega-cap concentration vs market broadening |
| 2 | Yield Curve | 10Y-2Y spread | 20% | Interest rate cycle transitions |
| 3 | Credit Conditions | HYG/LQD | 15% | Credit cycle risk appetite |
| 4 | Size Factor | IWM/SPY | 15% | Small vs large cap rotation |
| 5 | Equity-Bond | SPY/TLT + correlation | 15% | Stock-bond relationship regime |
| 6 | Sector Rotation | XLY/XLP | 10% | Cyclical vs defensive appetite |
5 Regime Classifications
- Concentration: Mega-cap leadership, narrow market
- Broadening: Expanding participation, small-cap/value rotation
- Contraction: Credit tightening, defensive rotation, risk-off
- Inflationary: Positive stock-bond correlation, traditional hedging fails
- Transitional: Multiple signals but unclear pattern
Output
macro_regime_YYYY-MM-DD_HHMMSS.json— Structured data for programmatic usemacro_regime_YYYY-MM-DD_HHMMSS.md— Human-readable report with:
1. Current Regime Assessment 2. Transition Signal Dashboard 3. Component Details 4. Regime Classification Evidence 5. Portfolio Posture Recommendations
Relationship to Other Skills
| Aspect | Macro Regime Detector | Market Top Detector | Market Breadth Analyzer |
|---|---|---|---|
| Time Horizon | 1-2 years (structural) | 2-8 weeks (tactical) | Current snapshot |
| Data Granularity | Monthly (6M/12M SMA) | Daily (25 business days) | Daily CSV |
| Detection Target | Regime transitions | 10-20% corrections | Breadth health score |
| API Calls | ~10 | ~33 | 0 (Free CSV) |
Script Arguments
python3 macro_regime_detector.py [options]
Options:
--api-key KEY FMP API key (default: $FMP_API_KEY)
--output-dir DIR Output directory (default: current directory)
--days N Days of history to fetch (default: 600)Resources
references/regime_detection_methodology.md— Detection methodology and signal interpretationreferences/indicator_interpretation_guide.md— Guide for interpreting cross-asset ratiosreferences/historical_regimes.md— Historical regime examples for context
Historical Regime Examples
Reference cases for the 5 regime classifications. Use these to contextualize current readings and identify historical parallels.
Concentration Regimes
2023-2024: Magnificent Seven Dominance
- RSP/SPY: Declined from ~0.32 to ~0.29, a multi-year low
- IWM/SPY: Persistent underperformance of small-caps
- Credit: Stable to easing (HYG/LQD steady)
- Characteristics: Top 7 stocks drove ~60% of S&P 500 returns
- Duration: ~18 months
- Resolution: Early signs of broadening in late 2024 (RSP/SPY bottoming)
- Portfolio posture: Mega-cap growth overweight was the only winning strategy
2019-2020 (Pre-COVID): FAANG+ Concentration
- RSP/SPY: Gradual decline through 2019
- IWM/SPY: Small-cap underperformance
- Ended: Abruptly with COVID crash (exogenous shock)
Broadening Regimes
2003-2004: Post-Dot-Com Recovery
- RSP/SPY: Rose significantly as value and small-caps recovered
- IWM/SPY: Strong small-cap outperformance
- Credit: HYG/LQD rising as credit conditions eased
- Yield curve: Steep (accommodative Fed)
- Duration: ~24 months
- Portfolio posture: Small-cap value, equal-weight strategies outperformed
2016-2017: Post-Election Broadening
- RSP/SPY: Rose as cyclicals and financials rallied
- IWM/SPY: Small-cap surge post-election
- Credit: Stable to improving
- XLY/XLP: Strong cyclical outperformance
- Duration: ~12 months
- Portfolio posture: Cyclicals, financials, small-cap growth
Contraction Regimes
2007-2009: Global Financial Crisis
- Credit: HYG/LQD collapsed (credit freeze)
- XLY/XLP: Extreme defensive rotation
- SPY/TLT: Sharp decline as bonds rallied
- Yield curve: Initially inverted, then steepened dramatically
- Duration: ~18 months (bear phase)
- Portfolio posture: Cash, Treasuries, defensive sectors
2020 Q1: COVID Crash
- Credit: HYG/LQD dropped sharply in March
- XLY/XLP: Rapid defensive rotation
- SPY/TLT: Crashed as equities sold off
- Duration: ~6 weeks (shortest contraction on record)
- Resolution: Massive fiscal/monetary response triggered V-shaped recovery
- Lesson: Not all contractions are prolonged; policy response matters
Inflationary Regimes
2022: Inflation Shock
- Stock-bond correlation: Turned positive (both fell together)
- SPY/TLT: Both declined simultaneously
- 10Y-2Y: Deep inversion (most inverted since 1980s)
- Yield curve: Aggressive flattening then inversion
- Duration: ~12 months
- Portfolio posture: Energy, commodities, real assets, short-duration bonds
- Key characteristic: Traditional 60/40 portfolio failed as bonds didn't hedge
1970s Stagflation (Historical Reference)
- Stock-bond correlation: Persistently positive
- Commodities: Outperformed financial assets
- Duration: Extended (years)
- Modern parallel: 2022 showed echoes but resolved faster
Transitional Regimes
2024 Q4 - 2025 Q1: Current Example
- RSP/SPY: Near historical lows (~0.297) but showing early bottoming signs
- IWM/SPY: Mixed signals, brief outperformance then reversal
- Credit: Stable (HYG/LQD holding)
- Yield curve: Normalizing from inversion
- XLY/XLP: Choppy, no clear direction
- Interpretation: Multiple indicators shifting but no clear new regime yet
- Portfolio posture: Maintain diversification, gradual position adjustments
2018: Failed Broadening
- RSP/SPY: Brief broadening attempt in early 2018
- Credit: Tightened in Q4 2018 (HYG selloff)
- Resolution: Fed pivot in early 2019 prevented full contraction
- Lesson: Transitional periods can resolve in any direction
Regime Transition Patterns
Common Sequences
1. Concentration → Broadening: Usually requires catalyst (Fed easing, fiscal stimulus, valuation compression in leaders)
2. Broadening → Contraction: Credit tightening is the typical trigger. Watch HYG/LQD for early warning.
3. Contraction → Broadening: Policy response (rate cuts, QE) → credit easing → small-cap recovery
4. Any → Inflationary: Positive stock-bond correlation is the key signal. Can emerge from either broadening or contraction.
Transition Timing
- Average regime duration: 12-24 months
- Transition phase: Typically 3-6 months of ambiguous signals
- Early signals appear 2-4 months before consensus recognition
- MA crossovers (6M vs 12M) confirm after the fact but provide low false-positive rate
False Signals
- 2018 Q1: Broadening signal reversed by Q4 (trade war + Fed tightening)
- 2015: Multiple signals of contraction that never fully materialized
- Lesson: Require 3+ months of sustained signals before high-conviction repositioning
Indicator Interpretation Guide
Quick reference for interpreting each component's output values and signals.
Component 1: Market Concentration (RSP/SPY)
What it measures: Relative performance of equal-weight S&P 500 vs cap-weight S&P 500.
| RSP/SPY Trend | Interpretation | Regime Signal |
|---|---|---|
| Declining | Mega-caps outperforming, market narrowing | Concentration |
| Rising | Broader participation, equal-weight catching up | Broadening |
| Bottoming + turning | Potential regime shift from concentration to broadening | Transition |
Key levels (approximate, varies over time):
- 0.28-0.29: Extreme concentration (2024 lows)
- 0.30-0.32: Moderate concentration
- 0.33+: Broadening phase
Crossover interpretation:
- Golden cross (6M > 12M): Broadening gaining momentum
- Death cross (6M < 12M): Concentration deepening
Component 2: Yield Curve (10Y-2Y Spread)
What it measures: Shape of the Treasury yield curve, reflecting rate cycle position.
| Spread Level | Curve State | Typical Environment |
|---|---|---|
| < -0.5% | Deeply inverted | Pre-recession, aggressive tightening |
| -0.5% to 0% | Inverted | Late-cycle, recession risk elevated |
| 0% to 0.5% | Flat/Normalizing | Transition period |
| 0.5% to 1.5% | Normal | Mid-cycle expansion |
| > 1.5% | Steep | Early recovery, accommodative policy |
Direction signals:
- Steepening: Either rates normalizing (bull steepener) or long end rising (bear steepener)
- Flattening: Late-cycle tightening or flight to long bonds
Fallback (SHY/TLT proxy): When Treasury API is unavailable, SHY/TLT ratio provides a rough proxy. Rising SHY/TLT ≈ flattening curve. Less precise than actual spread data.
Component 3: Credit Conditions (HYG/LQD)
What it measures: Risk appetite in credit markets — willingness to hold junk bonds vs investment grade.
| HYG/LQD Trend | Interpretation | Regime Signal |
|---|---|---|
| Rising | Credit risk appetite expanding | Risk-on, easing |
| Falling | Flight to quality | Risk-off, tightening |
| Stable | Established credit regime | No transition |
Why it matters: Credit markets often lead equity markets. HYG/LQD deterioration preceded the 2020 crash by ~2 weeks and the 2008 crisis by ~3 months.
Warning levels:
- Sharp drop (ROC < -3% over 3 months): Potential credit event
- Persistent decline with negative ROC: Late-cycle deterioration
- Stable with positive ROC: Supportive environment for risk assets
Component 4: Size Factor (IWM/SPY)
What it measures: Relative performance of small-caps (Russell 2000) vs large-caps (S&P 500).
| IWM/SPY Trend | Interpretation | Regime Signal |
|---|---|---|
| Rising | Small-caps outperforming — economic optimism | Broadening |
| Falling | Large-cap preference — defensive/quality bias | Concentration |
| Diverging from RSP/SPY | Inconsistent signal — check credit conditions | Uncertain |
Cycle position:
- Small-cap outperformance often starts 3-6 months before economic recovery becomes consensus
- Small-cap underperformance accelerates in late-cycle as credit conditions tighten
- IWM/SPY and RSP/SPY usually move together; divergence warrants investigation
Component 5: Equity-Bond Relationship (SPY/TLT + Correlation)
What it measures: Two aspects of the stock-bond relationship.
SPY/TLT Ratio
| SPY/TLT Trend | Interpretation |
|---|---|
| Rising | Equities outperforming bonds (risk-on) |
| Falling | Bonds outperforming equities (risk-off) |
Stock-Bond Correlation (6-month rolling)
| Correlation | Regime | Implication |
|---|---|---|
| < -0.3 | Negative (normal) | Bonds effectively hedge equity risk |
| -0.3 to 0 | Mildly negative | Hedging works but weakened |
| 0 to 0.3 | Near zero | Transitional — hedging unreliable |
| > 0.3 | Positive (inflationary) | Both move together — diversification fails |
Critical signal: Correlation sign change (negative → positive or vice versa) is one of the most important regime signals. Positive correlation typically occurs during:
- Inflation shocks (2022)
- Stagflation concerns
- Central bank credibility crises
Correlation bonus scoring: When 6M and 12M correlation have opposite signs, an additional 20 points are added to the component score, reflecting the significance of this regime shift.
Component 6: Sector Rotation (XLY/XLP)
What it measures: Consumer sentiment through discretionary vs staples spending preference.
| XLY/XLP Trend | Interpretation | Regime Signal |
|---|---|---|
| Rising | Consumer confidence, risk appetite | Risk-on, broadening |
| Falling | Defensive positioning, consumer caution | Risk-off, contraction |
| Stable | Established consumer sentiment | No transition |
Why Consumer Discretionary vs Staples:
- Most direct consumer-facing comparison
- Staples demand is relatively inelastic; discretionary is highly cyclical
- XLY includes Amazon, Tesla — captures both consumer and growth sentiment
- XLP is pure defensive (Procter & Gamble, Coca-Cola, Costco)
Cross-Component Analysis
Confirmation Patterns
Strong Broadening Confirmation (4+ components aligned):
- RSP/SPY ↑ + IWM/SPY ↑ + HYG/LQD stable/↑ + XLY/XLP ↑
Strong Contraction Confirmation:
- HYG/LQD ↓ + XLY/XLP ↓ + SPY/TLT ↓ + Yield curve steepening
Inflationary Confirmation:
- Stock-bond correlation positive + SPY/TLT ↓ + Yield curve behavior unusual
Divergence Signals
- RSP/SPY ↑ but HYG/LQD ↓: Broadening without credit support — fragile
- IWM/SPY ↑ but XLY/XLP ↓: Small-cap rally without consumer backing — suspicious
- Yield curve steepening but HYG/LQD ↓: Rate cuts due to crisis, not growth
Score Interpretation Quick Reference
| Composite Score | Zone | Action |
|---|---|---|
| 0-20 | Stable | Maintain current positioning |
| 21-40 | Early Signal | Increase monitoring frequency |
| 41-60 | Transition Zone | Begin planning adjustments |
| 61-80 | Active Transition | Execute repositioning |
| 81-100 | Confirmed | Complete repositioning |
Regime Detection Methodology
Overview
The Macro Regime Detector uses a 3-layer signal detection approach applied to 6 cross-asset ratios, analyzed at monthly frequency with 6-month and 12-month Simple Moving Averages.
Data Pipeline
Daily OHLCV (600 days, ~2.4 years)
→ Monthly Downsampling (last business day per month, ~24 points)
→ Ratio Calculation (e.g., RSP close / SPY close)
→ Moving Average Computation (6M SMA, 12M SMA)
→ Signal Detection (3 layers)
→ Component Scoring (0-100 per component)
→ Weighted Composite (6 components)
→ Regime Classification (decision tree)
→ Transition Probability AssessmentMonthly Downsampling
Daily data is downsampled to monthly frequency by selecting the most recent trading day in each calendar month. This filters out daily noise and focuses on structural trends.
Why monthly: Regime transitions are structural (1-2 year) phenomena. Daily or weekly data introduces noise without adding signal for this time horizon.
3-Layer Signal Detection
Layer 1: MA Crossover (0-40 points)
The primary signal is the relationship between the 6-month and 12-month SMA of each ratio.
- Golden Cross: 6M SMA crosses above 12M SMA (recent = 40pts, older = 20pts)
- Death Cross: 6M SMA crosses below 12M SMA (recent = 40pts, older = 20pts)
- Converging: SMAs within 1% gap, no crossover yet (0-25pts based on proximity)
- None: SMAs well separated, established trend (0pts)
Recency matters: A crossover 1-2 months ago is much more actionable than one 6+ months ago.
Layer 2: Momentum Shift (0-30 points)
Compares 3-month Rate of Change (ROC) against 12-month ROC to detect early reversals.
- Reversal Signal: 12M ROC negative but 3M ROC positive (or vice versa). This means the long-term trend is one direction but short-term momentum has reversed — an early warning of transition.
- Acceleration: Strong 3M ROC in the same direction as 12M ROC confirms an existing move.
Scoring scales linearly with the magnitude of the short-term ROC (capped at 5%).
Layer 3: Cross-Confirmation (0-30 points)
Checks alignment of multiple signals for confirmation:
1. Crossover is present (+10) 2. Short-term ROC confirms the crossover direction (+10) 3. SMA gap is widening (momentum building) (+10)
When all three align, the transition signal is strong (80-100 range).
Component Scoring Scale
Each component produces a score from 0 to 100, representing transition signal strength (not "good" or "bad"):
| Range | Interpretation |
|---|---|
| 0-20 | Stable regime, no transition signal |
| 20-40 | Minor fluctuation, possibly noise |
| 40-60 | Transition zone — MAs converging, potential crossover in 3-6 months |
| 60-80 | Clear transition signal — recent crossover or sharp momentum reversal |
| 80-100 | Strong confirmed transition — crossover + momentum + acceleration aligned |
Composite Score
The 6 component scores are combined using fixed weights:
| Component | Weight | Rationale |
|---|---|---|
| Market Concentration (RSP/SPY) | 25% | Primary indicator of market structure |
| Yield Curve (10Y-2Y) | 20% | Most reliable macro cycle indicator |
| Credit Conditions (HYG/LQD) | 15% | Leading indicator of financial stress |
| Size Factor (IWM/SPY) | 15% | Economic sentiment barometer |
| Equity-Bond (SPY/TLT) | 15% | Cross-asset risk regime |
| Sector Rotation (XLY/XLP) | 10% | Consumer sentiment proxy |
Regime Classification
A decision-tree approach scores each of 5 possible regimes based on component directions:
Scoring Rules
Concentration (+2 each):
- RSP/SPY direction = "concentrating"
- IWM/SPY direction = "large_cap_leading"
- Credit direction = stable/easing (+1)
Broadening (+2 each):
- RSP/SPY direction = "broadening"
- IWM/SPY direction = "small_cap_leading"
- Credit direction = stable/easing (+1)
- XLY/XLP direction = risk_on (+1)
Contraction (+2 each):
- Credit direction = "tightening"
- XLY/XLP direction = "risk_off"
- SPY/TLT direction = "risk_off" (+1)
Inflationary (+3):
- Stock-bond correlation = "positive"
- SPY/TLT direction = "risk_off" (+1)
Transitional: Assigned when 3+ components are signaling (score >= 40) but no regime scores >= 3.
Confidence Levels
- High: Best regime score >= 4
- Moderate: Best regime score >= 3
- Low: Best regime score >= 2
- Very Low: Best regime score < 2
Transition Probability
Combines signaling count and average component scores:
| Signaling Count | Avg Score | Probability |
|---|---|---|
| 4+ | >= 50 | High (70-90%) |
| 3+ | >= 40 | Moderate (40-60%) |
| 2+ | >= 30 | Low (20-40%) |
| < 2 | < 30 | Minimal (<20%) |
Limitations
1. Lagging by design: Monthly frequency means signals appear weeks to months after daily-frequency indicators. This is intentional — the goal is structural confirmation, not early detection.
2. False positives: Converging MAs can generate transition signals that reverse before completing. Always check cross-component confirmation.
3. Regime overlap: Real markets often exhibit characteristics of multiple regimes simultaneously. The "Transitional" classification explicitly acknowledges this.
4. Historical bias: Regime classification rules are derived from post-2000 market patterns. Novel regimes may not fit existing categories.
"""Macro Regime Detector - Calculator modules"""
#!/usr/bin/env python3
"""
Component 1: Market Concentration (Weight: 25%)
Analyzes RSP/SPY ratio to detect mega-cap concentration vs market broadening.
RSP (equal-weight S&P 500) / SPY (cap-weight S&P 500):
- Declining ratio = increasing concentration in mega-caps
- Rising ratio = market broadening, more stocks participating
Scoring (0-100 = Transition Signal Strength):
0-20: Stable regime, no transition signal
20-40: Minor fluctuation, possibly noise
40-60: Transition zone (MAs converging, crossover possible in 3-6 months)
60-80: Clear transition signal (recent crossover or sharp momentum reversal)
80-100: Strong confirmed transition (crossover + momentum + acceleration aligned)
"""
from typing import Optional
from .utils import (
STALE_CROSSOVER_MONTHS,
calculate_ratio,
compute_percentile,
compute_roc,
compute_sma,
detect_crossover,
downsample_to_monthly,
score_transition_signal,
)
def calculate_concentration(rsp_history: list[dict], spy_history: list[dict]) -> dict:
"""
Calculate market concentration transition signal from RSP/SPY ratio.
Args:
rsp_history: RSP daily OHLCV (most recent first)
spy_history: SPY daily OHLCV (most recent first)
Returns:
Dict with score (0-100), signal, ratio details, MA values, momentum
"""
if not rsp_history or not spy_history:
return _insufficient_data("No RSP or SPY data available")
# Downsample to monthly
rsp_monthly = downsample_to_monthly(rsp_history)
spy_monthly = downsample_to_monthly(spy_history)
if len(rsp_monthly) < 12 or len(spy_monthly) < 12:
return _insufficient_data("Insufficient monthly data (need >= 12 months)")
# Calculate RSP/SPY ratio (monthly)
ratio_series = calculate_ratio(rsp_monthly, spy_monthly)
if len(ratio_series) < 12:
return _insufficient_data("Insufficient ratio data")
# Current ratio
current_ratio = ratio_series[0]["value"]
current_date = ratio_series[0]["date"]
# Compute 6M and 12M SMAs on ratio
ratio_values = [r["value"] for r in ratio_series]
sma_6m = compute_sma(ratio_values, 6)
sma_12m = compute_sma(ratio_values, 12)
# Crossover detection (6M vs 12M)
crossover = detect_crossover(ratio_values, short_period=6, long_period=12)
# Momentum: 3-month and 12-month ROC
roc_3m = compute_roc(ratio_values, 3)
roc_12m = compute_roc(ratio_values, 12)
# Percentile of current ratio within historical range
percentile = compute_percentile(ratio_values, current_ratio)
# Score the transition signal
score = score_transition_signal(
crossover=crossover,
roc_short=roc_3m,
roc_long=roc_12m,
sma_short=sma_6m,
sma_long=sma_12m,
)
# Determine signal description
signal = _describe_signal(score, crossover, roc_3m, roc_12m, current_ratio)
# Determine direction (concentration-specific: SMA fallback preserved)
bars_ago = crossover.get("bars_ago")
is_stale = bars_ago is not None and bars_ago >= STALE_CROSSOVER_MONTHS
if crossover["type"] == "golden_cross":
direction = "broadening"
elif crossover["type"] == "death_cross":
direction = "concentrating"
elif sma_6m is not None and sma_12m is not None:
direction = "broadening" if sma_6m > sma_12m else "concentrating"
else:
direction = "unknown"
# Stale crossover override: if momentum contradicts, flip direction
momentum_qualifier = "N/A"
if crossover["type"] in ("golden_cross", "death_cross"):
cross_dir = "broadening" if crossover["type"] == "golden_cross" else "concentrating"
mom_dir = (
"broadening"
if roc_3m is not None and roc_3m > 0
else "concentrating"
if roc_3m is not None and roc_3m < 0
else None
)
if is_stale and mom_dir and mom_dir != cross_dir:
direction = mom_dir
momentum_qualifier = "reversing"
elif mom_dir == cross_dir:
momentum_qualifier = "confirmed"
elif mom_dir and mom_dir != cross_dir:
momentum_qualifier = "fading"
return {
"score": score,
"signal": signal,
"data_available": True,
"direction": direction,
"momentum_qualifier": momentum_qualifier,
"current_ratio": round(current_ratio, 4),
"current_date": current_date,
"sma_6m": round(sma_6m, 4) if sma_6m is not None else None,
"sma_12m": round(sma_12m, 4) if sma_12m is not None else None,
"roc_3m": round(roc_3m, 2) if roc_3m is not None else None,
"roc_12m": round(roc_12m, 2) if roc_12m is not None else None,
"percentile": round(percentile, 1) if percentile is not None else None,
"crossover": crossover,
"monthly_points": len(ratio_series),
}
def _describe_signal(
score: int, crossover: dict, roc_3m: Optional[float], roc_12m: Optional[float], ratio: float
) -> str:
if score >= 80:
return f"STRONG TRANSITION: RSP/SPY {crossover['type'].replace('_', ' ')} confirmed with aligned momentum"
elif score >= 60:
return "TRANSITION SIGNAL: RSP/SPY crossover or sharp momentum reversal detected"
elif score >= 40:
return "TRANSITION ZONE: RSP/SPY MAs converging, potential crossover ahead"
elif score >= 20:
return f"MINOR SHIFT: RSP/SPY ratio showing slight change ({ratio:.4f})"
else:
return f"STABLE: RSP/SPY ratio in established trend ({ratio:.4f})"
def _insufficient_data(reason: str) -> dict:
return {
"score": 0,
"signal": f"INSUFFICIENT DATA: {reason}",
"data_available": False,
"direction": "unknown",
"current_ratio": None,
"current_date": None,
"sma_6m": None,
"sma_12m": None,
"roc_3m": None,
"roc_12m": None,
"percentile": None,
"crossover": {"type": "none", "bars_ago": None},
"monthly_points": 0,
}
#!/usr/bin/env python3
"""
Component 3: Credit Conditions (Weight: 15%)
Analyzes HYG/LQD ratio to detect credit cycle transitions.
HYG (high-yield corporate bonds) / LQD (investment-grade corporate bonds):
- Rising ratio = credit risk appetite expanding, risk-on
- Falling ratio = flight to quality, credit tightening, risk-off
Transition signals:
- HYG/LQD turning up from bottom = credit cycle easing
- HYG/LQD turning down from top = credit cycle tightening
"""
from .utils import (
calculate_ratio,
compute_percentile,
compute_roc,
compute_sma,
detect_crossover,
determine_direction,
downsample_to_monthly,
score_transition_signal,
)
def calculate_credit_conditions(hyg_history: list[dict], lqd_history: list[dict]) -> dict:
"""
Calculate credit conditions transition signal from HYG/LQD ratio.
Args:
hyg_history: HYG daily OHLCV (most recent first)
lqd_history: LQD daily OHLCV (most recent first)
Returns:
Dict with score (0-100), signal, ratio details
"""
if not hyg_history or not lqd_history:
return _insufficient_data("No HYG or LQD data available")
hyg_monthly = downsample_to_monthly(hyg_history)
lqd_monthly = downsample_to_monthly(lqd_history)
if len(hyg_monthly) < 12 or len(lqd_monthly) < 12:
return _insufficient_data("Insufficient monthly data (need >= 12 months)")
ratio_series = calculate_ratio(hyg_monthly, lqd_monthly)
if len(ratio_series) < 12:
return _insufficient_data("Insufficient ratio data")
ratio_values = [r["value"] for r in ratio_series]
current_ratio = ratio_values[0]
current_date = ratio_series[0]["date"]
sma_6m = compute_sma(ratio_values, 6)
sma_12m = compute_sma(ratio_values, 12)
crossover = detect_crossover(ratio_values, short_period=6, long_period=12)
roc_3m = compute_roc(ratio_values, 3)
roc_12m = compute_roc(ratio_values, 12)
percentile = compute_percentile(ratio_values, current_ratio)
score = score_transition_signal(
crossover=crossover,
roc_short=roc_3m,
roc_long=roc_12m,
sma_short=sma_6m,
sma_long=sma_12m,
)
# Direction
direction, momentum_qualifier = determine_direction(
crossover,
roc_3m,
positive_label="easing",
negative_label="tightening",
neutral_label="stable",
)
signal = _describe_signal(score, direction, current_ratio)
return {
"score": score,
"signal": signal,
"data_available": True,
"direction": direction,
"momentum_qualifier": momentum_qualifier,
"current_ratio": round(current_ratio, 4),
"current_date": current_date,
"sma_6m": round(sma_6m, 4) if sma_6m is not None else None,
"sma_12m": round(sma_12m, 4) if sma_12m is not None else None,
"roc_3m": round(roc_3m, 2) if roc_3m is not None else None,
"roc_12m": round(roc_12m, 2) if roc_12m is not None else None,
"percentile": round(percentile, 1) if percentile is not None else None,
"crossover": crossover,
"monthly_points": len(ratio_series),
}
def _describe_signal(score: int, direction: str, ratio: float) -> str:
if score >= 60:
return f"TRANSITION: Credit conditions {direction} (HYG/LQD={ratio:.4f})"
elif score >= 40:
return f"SHIFTING: Credit conditions {direction} (HYG/LQD={ratio:.4f})"
else:
return f"STABLE: Credit conditions stable (HYG/LQD={ratio:.4f})"
def _insufficient_data(reason: str) -> dict:
return {
"score": 0,
"signal": f"INSUFFICIENT DATA: {reason}",
"data_available": False,
"direction": "unknown",
"current_ratio": None,
"current_date": None,
"sma_6m": None,
"sma_12m": None,
"roc_3m": None,
"roc_12m": None,
"percentile": None,
"crossover": {"type": "none", "bars_ago": None},
"monthly_points": 0,
}
#!/usr/bin/env python3
"""
Component 5: Equity-Bond Relationship (Weight: 15%)
Analyzes SPY/TLT ratio and rolling stock-bond correlation to detect
changes in the equity-bond regime.
SPY/TLT ratio:
- Rising = equities outperforming bonds (risk-on)
- Falling = bonds outperforming equities (risk-off)
Stock-Bond Correlation:
- Negative correlation (normal): Bonds hedge equities
- Positive correlation (inflationary): Both move together, traditional hedging breaks
- Correlation sign change = major regime shift
Transition signals:
- SPY/TLT ratio crossover + correlation regime change = high confidence
"""
from typing import Optional
from .utils import (
calculate_ratio,
compute_percentile,
compute_roc,
compute_rolling_correlation,
compute_sma,
detect_crossover,
determine_direction,
downsample_to_monthly,
score_transition_signal,
)
def calculate_equity_bond(spy_history: list[dict], tlt_history: list[dict]) -> dict:
"""
Calculate equity-bond relationship transition signal.
Args:
spy_history: SPY daily OHLCV (most recent first)
tlt_history: TLT daily OHLCV (most recent first)
Returns:
Dict with score (0-100), signal, ratio and correlation details
"""
if not spy_history or not tlt_history:
return _insufficient_data("No SPY or TLT data available")
spy_monthly = downsample_to_monthly(spy_history)
tlt_monthly = downsample_to_monthly(tlt_history)
if len(spy_monthly) < 12 or len(tlt_monthly) < 12:
return _insufficient_data("Insufficient monthly data (need >= 12 months)")
# SPY/TLT ratio analysis
ratio_series = calculate_ratio(spy_monthly, tlt_monthly)
if len(ratio_series) < 12:
return _insufficient_data("Insufficient ratio data")
ratio_values = [r["value"] for r in ratio_series]
current_ratio = ratio_values[0]
current_date = ratio_series[0]["date"]
sma_6m = compute_sma(ratio_values, 6)
sma_12m = compute_sma(ratio_values, 12)
crossover = detect_crossover(ratio_values, short_period=6, long_period=12)
roc_3m = compute_roc(ratio_values, 3)
roc_12m = compute_roc(ratio_values, 12)
percentile = compute_percentile(ratio_values, current_ratio)
# Ratio transition score
ratio_score = score_transition_signal(
crossover=crossover,
roc_short=roc_3m,
roc_long=roc_12m,
sma_short=sma_6m,
sma_long=sma_12m,
)
# Rolling correlation analysis (monthly returns)
spy_returns = _compute_monthly_returns([m["close"] for m in spy_monthly])
tlt_returns = _compute_monthly_returns([m["close"] for m in tlt_monthly])
correlation_6m = compute_rolling_correlation(spy_returns, tlt_returns, 6)
correlation_12m = compute_rolling_correlation(spy_returns, tlt_returns, 12)
# Correlation regime
corr_regime = _classify_correlation_regime(correlation_6m, correlation_12m)
# Correlation transition bonus (0-20)
corr_bonus = 0
if correlation_6m is not None and correlation_12m is not None:
# Sign change between 6M and 12M correlation = regime transition
if (correlation_6m > 0) != (correlation_12m > 0):
corr_bonus = 20
elif abs(correlation_6m - correlation_12m) > 0.3:
corr_bonus = 10
# Combined score
score = min(100, ratio_score + corr_bonus)
# Direction
direction, momentum_qualifier = determine_direction(
crossover,
roc_3m,
positive_label="risk_on",
negative_label="risk_off",
neutral_label="neutral",
)
signal = _describe_signal(score, direction, corr_regime, current_ratio)
return {
"score": score,
"signal": signal,
"data_available": True,
"direction": direction,
"momentum_qualifier": momentum_qualifier,
"correlation_regime": corr_regime,
"current_ratio": round(current_ratio, 4),
"current_date": current_date,
"sma_6m": round(sma_6m, 4) if sma_6m is not None else None,
"sma_12m": round(sma_12m, 4) if sma_12m is not None else None,
"roc_3m": round(roc_3m, 2) if roc_3m is not None else None,
"roc_12m": round(roc_12m, 2) if roc_12m is not None else None,
"percentile": round(percentile, 1) if percentile is not None else None,
"correlation_6m": round(correlation_6m, 3) if correlation_6m is not None else None,
"correlation_12m": round(correlation_12m, 3) if correlation_12m is not None else None,
"crossover": crossover,
"monthly_points": len(ratio_series),
}
def _compute_monthly_returns(closes: list[float]) -> list[float]:
"""Compute month-over-month returns from closes (most recent first)."""
if len(closes) < 2:
return []
returns = []
for i in range(len(closes) - 1):
if closes[i + 1] != 0:
ret = (closes[i] - closes[i + 1]) / closes[i + 1]
returns.append(ret)
return returns
def _classify_correlation_regime(corr_6m: Optional[float], corr_12m: Optional[float]) -> str:
"""Classify the stock-bond correlation regime."""
if corr_6m is None:
return "unknown"
if corr_6m < -0.3:
return "negative_strong" # Normal hedging
elif corr_6m < 0:
return "negative_mild" # Weak hedging
elif corr_6m < 0.3:
return "near_zero" # Transitional
else:
return "positive" # Inflationary regime
def _describe_signal(score: int, direction: str, corr_regime: str, ratio: float) -> str:
corr_labels = {
"negative_strong": "strong negative correlation (normal hedging)",
"negative_mild": "mild negative correlation",
"near_zero": "near-zero correlation (transitional)",
"positive": "positive correlation (inflationary)",
"unknown": "unknown correlation",
}
corr_label = corr_labels.get(corr_regime, corr_regime)
if score >= 60:
return f"TRANSITION: Equity-bond shift to {direction}, {corr_label} (SPY/TLT={ratio:.4f})"
elif score >= 40:
return f"SHIFTING: Equity-bond trending {direction}, {corr_label} (SPY/TLT={ratio:.4f})"
else:
return f"STABLE: Equity-bond stable, {corr_label} (SPY/TLT={ratio:.4f})"
def _insufficient_data(reason: str) -> dict:
return {
"score": 0,
"signal": f"INSUFFICIENT DATA: {reason}",
"data_available": False,
"direction": "unknown",
"correlation_regime": "unknown",
"current_ratio": None,
"current_date": None,
"sma_6m": None,
"sma_12m": None,
"roc_3m": None,
"roc_12m": None,
"percentile": None,
"correlation_6m": None,
"correlation_12m": None,
"crossover": {"type": "none", "bars_ago": None},
"monthly_points": 0,
}
#!/usr/bin/env python3
"""
Component 6: Sector Rotation (Weight: 10%)
Analyzes XLY/XLP ratio to detect cyclical vs defensive appetite.
XLY (Consumer Discretionary) / XLP (Consumer Staples):
- Rising ratio = risk appetite expanding, consumer confidence
- Falling ratio = defensive positioning, consumer caution
This is a classic risk-on/risk-off barometer that captures
consumer-facing economic sentiment.
"""
from .utils import (
calculate_ratio,
compute_percentile,
compute_roc,
compute_sma,
detect_crossover,
determine_direction,
downsample_to_monthly,
score_transition_signal,
)
def calculate_sector_rotation(xly_history: list[dict], xlp_history: list[dict]) -> dict:
"""
Calculate sector rotation transition signal from XLY/XLP ratio.
Args:
xly_history: XLY daily OHLCV (most recent first)
xlp_history: XLP daily OHLCV (most recent first)
Returns:
Dict with score (0-100), signal, ratio details
"""
if not xly_history or not xlp_history:
return _insufficient_data("No XLY or XLP data available")
xly_monthly = downsample_to_monthly(xly_history)
xlp_monthly = downsample_to_monthly(xlp_history)
if len(xly_monthly) < 12 or len(xlp_monthly) < 12:
return _insufficient_data("Insufficient monthly data (need >= 12 months)")
ratio_series = calculate_ratio(xly_monthly, xlp_monthly)
if len(ratio_series) < 12:
return _insufficient_data("Insufficient ratio data")
ratio_values = [r["value"] for r in ratio_series]
current_ratio = ratio_values[0]
current_date = ratio_series[0]["date"]
sma_6m = compute_sma(ratio_values, 6)
sma_12m = compute_sma(ratio_values, 12)
crossover = detect_crossover(ratio_values, short_period=6, long_period=12)
roc_3m = compute_roc(ratio_values, 3)
roc_12m = compute_roc(ratio_values, 12)
percentile = compute_percentile(ratio_values, current_ratio)
score = score_transition_signal(
crossover=crossover,
roc_short=roc_3m,
roc_long=roc_12m,
sma_short=sma_6m,
sma_long=sma_12m,
)
# Direction
direction, momentum_qualifier = determine_direction(
crossover,
roc_3m,
positive_label="risk_on",
negative_label="risk_off",
neutral_label="neutral",
)
signal = _describe_signal(score, direction, current_ratio)
return {
"score": score,
"signal": signal,
"data_available": True,
"direction": direction,
"momentum_qualifier": momentum_qualifier,
"current_ratio": round(current_ratio, 4),
"current_date": current_date,
"sma_6m": round(sma_6m, 4) if sma_6m is not None else None,
"sma_12m": round(sma_12m, 4) if sma_12m is not None else None,
"roc_3m": round(roc_3m, 2) if roc_3m is not None else None,
"roc_12m": round(roc_12m, 2) if roc_12m is not None else None,
"percentile": round(percentile, 1) if percentile is not None else None,
"crossover": crossover,
"monthly_points": len(ratio_series),
}
def _describe_signal(score: int, direction: str, ratio: float) -> str:
if score >= 60:
return f"TRANSITION: Sector rotation to {direction} (XLY/XLP={ratio:.4f})"
elif score >= 40:
return f"SHIFTING: Sector rotation trending {direction} (XLY/XLP={ratio:.4f})"
else:
return f"STABLE: Sector rotation stable, {direction} (XLY/XLP={ratio:.4f})"
def _insufficient_data(reason: str) -> dict:
return {
"score": 0,
"signal": f"INSUFFICIENT DATA: {reason}",
"data_available": False,
"direction": "unknown",
"current_ratio": None,
"current_date": None,
"sma_6m": None,
"sma_12m": None,
"roc_3m": None,
"roc_12m": None,
"percentile": None,
"crossover": {"type": "none", "bars_ago": None},
"monthly_points": 0,
}
#!/usr/bin/env python3
"""
Component 4: Size Factor (Weight: 15%)
Analyzes IWM/SPY ratio to detect small-cap vs large-cap rotation.
IWM (Russell 2000) / SPY (S&P 500):
- Rising ratio = small-caps outperforming, risk-on, economic optimism
- Falling ratio = large-cap preference, defensive positioning
Transition signals:
- IWM/SPY turning up = broadening rally, cyclical recovery
- IWM/SPY turning down = flight to quality, narrowing leadership
"""
from .utils import (
calculate_ratio,
compute_percentile,
compute_roc,
compute_sma,
detect_crossover,
determine_direction,
downsample_to_monthly,
score_transition_signal,
)
def calculate_size_factor(iwm_history: list[dict], spy_history: list[dict]) -> dict:
"""
Calculate size factor transition signal from IWM/SPY ratio.
Args:
iwm_history: IWM daily OHLCV (most recent first)
spy_history: SPY daily OHLCV (most recent first)
Returns:
Dict with score (0-100), signal, ratio details
"""
if not iwm_history or not spy_history:
return _insufficient_data("No IWM or SPY data available")
iwm_monthly = downsample_to_monthly(iwm_history)
spy_monthly = downsample_to_monthly(spy_history)
if len(iwm_monthly) < 12 or len(spy_monthly) < 12:
return _insufficient_data("Insufficient monthly data (need >= 12 months)")
ratio_series = calculate_ratio(iwm_monthly, spy_monthly)
if len(ratio_series) < 12:
return _insufficient_data("Insufficient ratio data")
ratio_values = [r["value"] for r in ratio_series]
current_ratio = ratio_values[0]
current_date = ratio_series[0]["date"]
sma_6m = compute_sma(ratio_values, 6)
sma_12m = compute_sma(ratio_values, 12)
crossover = detect_crossover(ratio_values, short_period=6, long_period=12)
roc_3m = compute_roc(ratio_values, 3)
roc_12m = compute_roc(ratio_values, 12)
percentile = compute_percentile(ratio_values, current_ratio)
score = score_transition_signal(
crossover=crossover,
roc_short=roc_3m,
roc_long=roc_12m,
sma_short=sma_6m,
sma_long=sma_12m,
)
# Direction
direction, momentum_qualifier = determine_direction(
crossover,
roc_3m,
positive_label="small_cap_leading",
negative_label="large_cap_leading",
neutral_label="neutral",
)
signal = _describe_signal(score, direction, current_ratio)
return {
"score": score,
"signal": signal,
"data_available": True,
"direction": direction,
"momentum_qualifier": momentum_qualifier,
"current_ratio": round(current_ratio, 4),
"current_date": current_date,
"sma_6m": round(sma_6m, 4) if sma_6m is not None else None,
"sma_12m": round(sma_12m, 4) if sma_12m is not None else None,
"roc_3m": round(roc_3m, 2) if roc_3m is not None else None,
"roc_12m": round(roc_12m, 2) if roc_12m is not None else None,
"percentile": round(percentile, 1) if percentile is not None else None,
"crossover": crossover,
"monthly_points": len(ratio_series),
}
def _describe_signal(score: int, direction: str, ratio: float) -> str:
direction_labels = {
"small_cap_leading": "small-cap outperformance",
"large_cap_leading": "large-cap outperformance",
"neutral": "neutral size factor",
}
label = direction_labels.get(direction, direction)
if score >= 60:
return f"TRANSITION: Size factor shift to {label} (IWM/SPY={ratio:.4f})"
elif score >= 40:
return f"SHIFTING: Size factor trending toward {label} (IWM/SPY={ratio:.4f})"
else:
return f"STABLE: Size factor stable, {label} (IWM/SPY={ratio:.4f})"
def _insufficient_data(reason: str) -> dict:
return {
"score": 0,
"signal": f"INSUFFICIENT DATA: {reason}",
"data_available": False,
"direction": "unknown",
"current_ratio": None,
"current_date": None,
"sma_6m": None,
"sma_12m": None,
"roc_3m": None,
"roc_12m": None,
"percentile": None,
"crossover": {"type": "none", "bars_ago": None},
"monthly_points": 0,
}
#!/usr/bin/env python3
"""
Shared utility functions for macro regime calculators.
Provides monthly downsampling, ratio calculation, moving averages,
crossover detection, momentum computation, and transition scoring.
"""
from typing import Optional
def downsample_to_monthly(daily_history: list[dict]) -> list[dict]:
"""
Downsample daily OHLCV to monthly (last business day of each month).
Args:
daily_history: Daily bars, most recent first.
Returns:
List of monthly bars (most recent first), each with 'date', 'close'.
"""
if not daily_history:
return []
# Group by year-month, pick the most recent bar per month
monthly = {}
for bar in daily_history:
date_str = bar.get("date", "")
close = bar.get("adjClose", bar.get("close", 0))
if not date_str or close == 0:
continue
# Extract year-month key
ym = date_str[:7] # "YYYY-MM"
if ym not in monthly:
monthly[ym] = {"date": date_str, "close": close}
else:
# daily_history is most recent first, so first occurrence is the latest in that month
# Keep the first (most recent) bar for each month
pass
# Sort by date descending (most recent first)
result = sorted(monthly.values(), key=lambda x: x["date"], reverse=True)
return result
def calculate_ratio(numerator_monthly: list[dict], denominator_monthly: list[dict]) -> list[dict]:
"""
Calculate ratio of two monthly series aligned by date.
Args:
numerator_monthly: Monthly bars for numerator (most recent first)
denominator_monthly: Monthly bars for denominator (most recent first)
Returns:
List of {'date': str, 'value': float} (most recent first)
"""
# Build lookup by year-month
denom_lookup = {}
for bar in denominator_monthly:
ym = bar["date"][:7]
denom_lookup[ym] = bar["close"]
result = []
for bar in numerator_monthly:
ym = bar["date"][:7]
if ym in denom_lookup and denom_lookup[ym] != 0:
ratio = bar["close"] / denom_lookup[ym]
result.append({"date": bar["date"], "value": ratio})
return result
def compute_sma(values: list[float], period: int) -> Optional[float]:
"""
Compute Simple Moving Average from a list of values (most recent first).
Returns SMA of the most recent `period` values, or None if insufficient data.
"""
if len(values) < period:
return None
return sum(values[:period]) / period
def detect_crossover(values: list[float], short_period: int = 6, long_period: int = 12) -> dict:
"""
Detect SMA crossover between short and long periods.
Args:
values: Series of values (most recent first)
short_period: Short-term SMA period (default 6)
long_period: Long-term SMA period (default 12)
Returns:
Dict with 'type' ('golden_cross', 'death_cross', 'converging', 'none'),
'bars_ago' (how many months ago the crossover occurred),
'gap_pct' (current gap between short and long SMA as %)
"""
if len(values) < long_period + 3:
return {"type": "none", "bars_ago": None, "gap_pct": None}
# Compute SMAs at each point
max_lookback = min(len(values), long_period + 12) # Check up to 12 months back
sma_pairs = []
for offset in range(max_lookback - long_period + 1):
subset = values[offset:]
short_sma = compute_sma(subset, short_period)
long_sma = compute_sma(subset, long_period)
if short_sma is not None and long_sma is not None:
sma_pairs.append((short_sma, long_sma))
if len(sma_pairs) < 2:
return {"type": "none", "bars_ago": None, "gap_pct": None}
# Current gap
current_short, current_long = sma_pairs[0]
if current_long != 0:
gap_pct = (current_short - current_long) / current_long * 100
else:
gap_pct = 0
# Look for crossover
for i in range(1, len(sma_pairs)):
prev_short, prev_long = sma_pairs[i]
curr_short, curr_long = sma_pairs[i - 1]
# Golden cross: short crosses above long
if prev_short <= prev_long and curr_short > curr_long:
return {
"type": "golden_cross",
"bars_ago": i - 1,
"gap_pct": round(gap_pct, 3),
}
# Death cross: short crosses below long
if prev_short >= prev_long and curr_short < curr_long:
return {
"type": "death_cross",
"bars_ago": i - 1,
"gap_pct": round(gap_pct, 3),
}
# No crossover found, check if converging
if abs(gap_pct) < 1.0:
return {
"type": "converging",
"bars_ago": None,
"gap_pct": round(gap_pct, 3),
}
return {
"type": "none",
"bars_ago": None,
"gap_pct": round(gap_pct, 3),
}
def compute_roc(values: list[float], period: int) -> Optional[float]:
"""
Compute Rate of Change (%) over `period` data points.
Args:
values: Series (most recent first)
period: Number of periods back to compare
Returns:
ROC as percentage, or None if insufficient data
"""
if len(values) <= period:
return None
current = values[0]
past = values[period]
if past == 0:
return None
return (current - past) / past * 100
def compute_percentile(values: list[float], current: float) -> Optional[float]:
"""
Compute percentile rank of current value within the series.
Returns percentile (0-100), or None if insufficient data.
"""
if not values:
return None
below = sum(1 for v in values if v < current)
return below / len(values) * 100
def compute_rolling_correlation(
series_a: list[float], series_b: list[float], window: int
) -> Optional[float]:
"""
Compute rolling Pearson correlation between two series over a window.
Args:
series_a: First series (most recent first)
series_b: Second series (most recent first)
window: Number of data points for correlation window
Returns:
Correlation coefficient (-1 to 1), or None if insufficient data
"""
if len(series_a) < window or len(series_b) < window:
return None
a = series_a[:window]
b = series_b[:window]
n = window
mean_a = sum(a) / n
mean_b = sum(b) / n
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n)) / n
std_a = (sum((x - mean_a) ** 2 for x in a) / n) ** 0.5
std_b = (sum((x - mean_b) ** 2 for x in b) / n) ** 0.5
if std_a == 0 or std_b == 0:
return 0.0
return cov / (std_a * std_b)
STALE_CROSSOVER_MONTHS = 3
def determine_direction(
crossover: dict,
roc_3m: Optional[float],
positive_label: str,
negative_label: str,
neutral_label: str = "neutral",
) -> tuple[str, str]:
"""
Determine direction from crossover and momentum, accounting for stale crossovers.
When a crossover is old (>= STALE_CROSSOVER_MONTHS) and momentum contradicts it,
momentum takes priority ("reversing"). Recent crossovers always win.
Args:
crossover: Dict with 'type' and 'bars_ago'
roc_3m: 3-month rate of change (%)
positive_label: Label for positive direction (e.g., "risk_on")
negative_label: Label for negative direction (e.g., "risk_off")
neutral_label: Label when no signal (default "neutral")
Returns:
Tuple of (direction, momentum_qualifier)
momentum_qualifier: "confirmed" | "fading" | "reversing" | "N/A"
"""
cross_type = crossover.get("type", "none")
bars_ago = crossover.get("bars_ago")
is_stale = bars_ago is not None and bars_ago >= STALE_CROSSOVER_MONTHS
# Crossover direction
cross_dir = (
positive_label
if cross_type == "golden_cross"
else negative_label
if cross_type == "death_cross"
else None
)
# Momentum direction
mom_dir = (
positive_label
if roc_3m is not None and roc_3m > 0
else negative_label
if roc_3m is not None and roc_3m < 0
else None
)
if cross_dir:
if is_stale and mom_dir and mom_dir != cross_dir:
return mom_dir, "reversing"
qualifier = (
"confirmed"
if mom_dir == cross_dir
else "fading"
if mom_dir and mom_dir != cross_dir
else "N/A"
)
return cross_dir, qualifier
elif mom_dir:
return mom_dir, "N/A"
else:
return neutral_label, "N/A"
def score_transition_signal(
crossover: dict,
roc_short: Optional[float],
roc_long: Optional[float],
sma_short: Optional[float],
sma_long: Optional[float],
) -> int:
"""
Score transition signal strength (0-100) from crossover, momentum, and MA data.
Scoring layers:
1. MA Crossover (0-40 base): Recent crossover = high, converging = moderate
2. Momentum Shift (0-30): Short ROC reversal against long trend = early warning
3. Confirmation (0-30): Multiple signals aligning = strong confirmation
Returns:
int score 0-100
"""
score = 0
# Layer 1: Crossover detection (0-40)
cross_type = crossover.get("type", "none")
bars_ago = crossover.get("bars_ago")
gap_pct = crossover.get("gap_pct", 0) or 0
if cross_type in ("golden_cross", "death_cross"):
if bars_ago is not None and bars_ago <= 2:
score += 40 # Very recent crossover
elif bars_ago is not None and bars_ago <= 5:
score += 30 # Recent crossover
else:
score += 20 # Older crossover
elif cross_type == "converging":
# MAs getting close, crossover possible
closeness = max(0, 1.0 - abs(gap_pct)) * 25
score += int(closeness)
# "none" = stable, no points
# Layer 2: Momentum shift (0-30)
if roc_short is not None and roc_long is not None:
# Reversal: short-term momentum opposite to long-term trend
if roc_long < 0 and roc_short > 0:
# Declining long-term but short-term bouncing = early reversal
strength = min(abs(roc_short), 5.0) / 5.0 * 30
score += int(strength)
elif roc_long > 0 and roc_short < 0:
# Rising long-term but short-term declining = early reversal
strength = min(abs(roc_short), 5.0) / 5.0 * 30
score += int(strength)
elif abs(roc_short) > 3.0:
# Strong short-term momentum in same direction = acceleration
score += 10
# Layer 3: Confirmation / alignment (0-30)
signals_aligned = 0
# Signal 1: Crossover present
if cross_type in ("golden_cross", "death_cross"):
signals_aligned += 1
# Signal 2: Short ROC confirms direction
if cross_type == "golden_cross" and roc_short is not None and roc_short > 0:
signals_aligned += 1
elif cross_type == "death_cross" and roc_short is not None and roc_short < 0:
signals_aligned += 1
# Signal 3: SMA gap widening (momentum)
if sma_short is not None and sma_long is not None and sma_long != 0:
current_gap = abs(sma_short - sma_long) / sma_long * 100
if current_gap > 0.5:
signals_aligned += 1
score += signals_aligned * 10
return min(100, max(0, score))
#!/usr/bin/env python3
"""
Component 2: Yield Curve (Weight: 20%)
Analyzes 10Y-2Y Treasury spread to detect interest rate cycle transitions.
Primary data: Treasury API (10Y-2Y spread directly)
Fallback: SHY/TLT ratio as proxy for yield curve shape
Transition signals:
- Inversion → Normalization: Often precedes recession end, risk-on shift
- Steepening from flat: Economic recovery signal
- Flattening from steep: Late-cycle tightening signal
- Deep inversion: Recession warning
Scoring (0-100 = Transition Signal Strength):
0-20: Stable yield curve regime
20-40: Minor changes in spread
40-60: Transition zone (curve shape changing)
60-80: Clear transition (inversion/normalization crossover)
80-100: Strong confirmed transition
"""
from typing import Optional
from .utils import (
calculate_ratio,
compute_percentile,
compute_roc,
compute_sma,
detect_crossover,
downsample_to_monthly,
score_transition_signal,
)
def calculate_yield_curve(
treasury_rates: Optional[list[dict]] = None,
shy_history: Optional[list[dict]] = None,
tlt_history: Optional[list[dict]] = None,
) -> dict:
"""
Calculate yield curve transition signal.
Args:
treasury_rates: Treasury rate data from FMP stable API (most recent first)
shy_history: SHY daily OHLCV (fallback, most recent first)
tlt_history: TLT daily OHLCV (fallback, most recent first)
Returns:
Dict with score, signal, spread details, curve state
"""
# Try Treasury API first
if treasury_rates:
result = _analyze_treasury_spread(treasury_rates)
if result is not None:
return result
# Fallback to SHY/TLT ratio
if shy_history and tlt_history:
return _analyze_shy_tlt_proxy(shy_history, tlt_history)
return _insufficient_data("No treasury rates or SHY/TLT data available")
def _analyze_treasury_spread(treasury_rates: list[dict]) -> Optional[dict]:
"""Analyze 10Y-2Y spread from Treasury API data."""
# Extract 10Y-2Y spread series
spread_monthly = {}
for entry in treasury_rates:
date_str = entry.get("date", "")
year10 = entry.get("year10")
year2 = entry.get("year2")
if not date_str or year10 is None or year2 is None:
continue
try:
y10 = float(year10)
y2 = float(year2)
except (ValueError, TypeError):
continue
ym = date_str[:7]
if ym not in spread_monthly:
spread_monthly[ym] = {
"date": date_str,
"spread": y10 - y2,
"year10": y10,
"year2": y2,
}
if len(spread_monthly) < 12:
return None
# Sort most recent first
spread_series = sorted(spread_monthly.values(), key=lambda x: x["date"], reverse=True)
spread_values = [s["spread"] for s in spread_series]
current_spread = spread_values[0]
current_date = spread_series[0]["date"]
current_10y = spread_series[0]["year10"]
current_2y = spread_series[0]["year2"]
# Compute SMAs
sma_6m = compute_sma(spread_values, 6)
sma_12m = compute_sma(spread_values, 12)
# Crossover detection
crossover = detect_crossover(spread_values, short_period=6, long_period=12)
# Individual yield ROCs for steepening type classification
y10_values = [s["year10"] for s in spread_series]
y2_values = [s["year2"] for s in spread_series]
roc_3m_10y = compute_roc(y10_values, 3)
roc_3m_2y = compute_roc(y2_values, 3)
# Momentum
roc_3m = compute_roc(spread_values, 3)
roc_12m = compute_roc(spread_values, 12)
# Percentile
percentile = compute_percentile(spread_values, current_spread)
# Score
score = score_transition_signal(
crossover=crossover,
roc_short=roc_3m,
roc_long=roc_12m,
sma_short=sma_6m,
sma_long=sma_12m,
)
# Curve state
curve_state = _classify_curve_state(current_spread, sma_6m, roc_3m)
# Direction of transition
if roc_3m is not None and roc_3m > 0:
direction = "steepening"
elif roc_3m is not None and roc_3m < 0:
direction = "flattening"
else:
direction = "stable"
# Steepening type classification
steepening_type = None
if direction == "steepening":
if roc_3m_2y is not None and roc_3m_2y < 0:
steepening_type = "bull_steepener"
elif roc_3m_10y is not None and roc_3m_10y > 0:
steepening_type = "bear_steepener"
else:
steepening_type = "mixed_steepener"
signal = _describe_signal(score, curve_state, current_spread, direction)
return {
"score": score,
"signal": signal,
"data_available": True,
"data_source": "treasury_api",
"direction": direction,
"curve_state": curve_state,
"steepening_type": steepening_type,
"current_spread": round(current_spread, 3),
"current_10y": current_10y,
"current_2y": current_2y,
"current_date": current_date,
"sma_6m": round(sma_6m, 3) if sma_6m is not None else None,
"sma_12m": round(sma_12m, 3) if sma_12m is not None else None,
"roc_3m": round(roc_3m, 2) if roc_3m is not None else None,
"roc_12m": round(roc_12m, 2) if roc_12m is not None else None,
"roc_3m_10y": round(roc_3m_10y, 2) if roc_3m_10y is not None else None,
"roc_3m_2y": round(roc_3m_2y, 2) if roc_3m_2y is not None else None,
"percentile": round(percentile, 1) if percentile is not None else None,
"crossover": crossover,
"monthly_points": len(spread_series),
}
def _analyze_shy_tlt_proxy(shy_history: list[dict], tlt_history: list[dict]) -> dict:
"""Fallback: Use SHY/TLT ratio as yield curve proxy."""
shy_monthly = downsample_to_monthly(shy_history)
tlt_monthly = downsample_to_monthly(tlt_history)
if len(shy_monthly) < 12 or len(tlt_monthly) < 12:
return _insufficient_data("Insufficient SHY/TLT monthly data")
# SHY/TLT ratio: rising = curve flattening/inverting, falling = steepening
ratio_series = calculate_ratio(shy_monthly, tlt_monthly)
if len(ratio_series) < 12:
return _insufficient_data("Insufficient SHY/TLT ratio data")
ratio_values = [r["value"] for r in ratio_series]
current_ratio = ratio_values[0]
current_date = ratio_series[0]["date"]
sma_6m = compute_sma(ratio_values, 6)
sma_12m = compute_sma(ratio_values, 12)
crossover = detect_crossover(ratio_values, short_period=6, long_period=12)
roc_3m = compute_roc(ratio_values, 3)
roc_12m = compute_roc(ratio_values, 12)
percentile = compute_percentile(ratio_values, current_ratio)
score = score_transition_signal(
crossover=crossover,
roc_short=roc_3m,
roc_long=roc_12m,
sma_short=sma_6m,
sma_long=sma_12m,
)
# For SHY/TLT: rising ratio = flattening, falling = steepening
if roc_3m is not None and roc_3m < 0:
direction = "steepening"
elif roc_3m is not None and roc_3m > 0:
direction = "flattening"
else:
direction = "stable"
signal = f"PROXY (SHY/TLT): {direction} signal, score={score}"
return {
"score": score,
"signal": signal,
"data_available": True,
"data_source": "shy_tlt_proxy",
"direction": direction,
"curve_state": "proxy_only",
"steepening_type": None,
"current_spread": None,
"current_10y": None,
"current_2y": None,
"current_date": current_date,
"proxy_ratio": round(current_ratio, 4),
"sma_6m": round(sma_6m, 4) if sma_6m is not None else None,
"sma_12m": round(sma_12m, 4) if sma_12m is not None else None,
"roc_3m": round(roc_3m, 2) if roc_3m is not None else None,
"roc_12m": round(roc_12m, 2) if roc_12m is not None else None,
"percentile": round(percentile, 1) if percentile is not None else None,
"crossover": crossover,
"monthly_points": len(ratio_series),
}
def _classify_curve_state(spread: float, sma_6m: Optional[float], roc_3m: Optional[float]) -> str:
"""Classify current yield curve state."""
if spread < -0.5:
return "deeply_inverted"
elif spread < 0:
return "inverted"
elif spread < 0.5:
if roc_3m is not None and roc_3m > 0:
return "normalizing"
return "flat"
elif spread < 1.5:
return "normal"
else:
return "steep"
def _describe_signal(score: int, state: str, spread: float, direction: str) -> str:
state_labels = {
"deeply_inverted": "Deeply Inverted",
"inverted": "Inverted",
"normalizing": "Normalizing",
"flat": "Flat",
"normal": "Normal",
"steep": "Steep",
}
state_label = state_labels.get(state, state)
if score >= 60:
return f"TRANSITION: Yield curve {direction} ({state_label}, spread={spread:+.3f}%)"
elif score >= 40:
return f"SHIFTING: Yield curve {direction} ({state_label}, spread={spread:+.3f}%)"
else:
return f"STABLE: Yield curve {state_label} (spread={spread:+.3f}%)"
def _insufficient_data(reason: str) -> dict:
return {
"score": 0,
"signal": f"INSUFFICIENT DATA: {reason}",
"data_available": False,
"data_source": "none",
"direction": "unknown",
"curve_state": "unknown",
"current_spread": None,
"current_10y": None,
"current_2y": None,
"current_date": None,
"sma_6m": None,
"sma_12m": None,
"roc_3m": None,
"roc_12m": None,
"percentile": None,
"crossover": {"type": "none", "bars_ago": None},
"monthly_points": 0,
}
#!/usr/bin/env python3
# GENERATED by scripts/generate_fmp_client.py — do not edit.
# Source of truth: scripts/fmp_client/specials/ and scripts/fmp_client/registry.py.
# Regenerate: python3 scripts/generate_fmp_client.py
"""
FMP API Client for Macro Regime Detector
Provides rate-limited access to Financial Modeling Prep API endpoints
for macro regime detection analysis.
Features:
- Rate limiting (0.3s between requests)
- Automatic retry on 429 errors
- Session caching for duplicate requests
- Batch historical data support
- Treasury rates endpoint support
"""
import os
import sys
import time
from datetime import date, timedelta
from typing import Optional
try:
import requests
except ImportError:
print("ERROR: requests library not found. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
# --- FMP endpoint fallback: stable (new users) -> v3 (legacy users) ---
def _stable_hist_url(base, symbols_str, params):
"""stable/historical-price-eod/full?symbol=SPY&from=...&to=..."""
params["symbol"] = symbols_str
# New stable EOD endpoint ignores `timeseries`; convert to from/to range
# to bound the payload. Use 2x calendar days to cover N trading days
# (trading-day/calendar-day ratio ~252/365 ~0.69, so *2 leaves headroom).
days = params.pop("timeseries", None)
if days is not None:
today = date.today()
params["from"] = (today - timedelta(days=int(days) * 2)).isoformat()
params["to"] = today.isoformat()
return base, params
def _v3_hist_url(base, symbols_str, params):
"""api/v3/historical-price-full/SPY?timeseries=600"""
return f"{base}/{symbols_str}", params
_FMP_ENDPOINTS = {
"historical": [
("https://financialmodelingprep.com/stable/historical-price-eod/full", _stable_hist_url),
("https://financialmodelingprep.com/api/v3/historical-price-full", _v3_hist_url),
],
}
def _normalize_eod_flat_list(data, symbols_str: str, limit: Optional[int] = None):
"""Convert stable/historical-price-eod/full flat list to v3-compatible dict.
Input : [{"symbol": "SPY", "date": "...", "open": ..., ...}, ...]
Output : {"symbol": "SPY", "historical": [{"date": ..., "open": ..., ...}, ...]}
Returns the input unchanged if not a list (passthrough for v3 dict /
historicalStockList responses). Returns None when no row matches the
requested symbol; the caller will record the failure and try the next
endpoint.
If `limit` is provided (the original `timeseries=N` request), the
`historical` list is truncated to the first `limit` entries. The new
EOD endpoint ignores `timeseries` and returns the full available history,
so the caller's date-range bounding plus this truncation together preserve
the legacy "most-recent N rows" contract. Truncation assumes descending
date order, which the FMP EOD endpoint provides (verified live).
Note: empty list ``[]`` does not reach this normalizer because the caller's
``if not data: continue`` falsy check handles it earlier in
``_request_with_fallback``.
"""
if not isinstance(data, list):
return data
if not data:
return None
norm_target = symbols_str.replace("-", ".")
matched_symbol = None
historical = []
for row in data:
if not isinstance(row, dict):
continue
# Be permissive: single-symbol endpoint may omit per-row "symbol".
# Treat missing symbol as belonging to the requested symbols_str.
row_sym = row.get("symbol") or symbols_str
if row_sym.replace("-", ".") != norm_target:
continue
matched_symbol = matched_symbol or row_sym
historical.append({k: v for k, v in row.items() if k != "symbol"})
if not historical:
return None
if limit is not None and limit > 0:
historical = historical[:limit]
return {"symbol": matched_symbol or symbols_str, "historical": historical}
def _has_usable_history(data) -> bool:
"""True only when `data` is a dict carrying a non-empty `historical` list.
A dict with an empty `historical` list (e.g. an ETF unavailable on the
caller's FMP plan) is treated as unusable so the caller can fall back to
yfinance instead of caching an empty result.
"""
return bool(
isinstance(data, dict) and isinstance(data.get("historical"), list) and data["historical"]
)
class FMPClient:
"""Client for Financial Modeling Prep API with rate limiting and caching"""
BASE_URL = "https://financialmodelingprep.com/api/v3"
STABLE_URL = "https://financialmodelingprep.com/stable"
RATE_LIMIT_DELAY = 0.3 # 300ms between requests
_ENDPOINT_FAILURE_THRESHOLD = 3
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("FMP_API_KEY")
if not self.api_key:
raise ValueError(
"FMP API key required. Set FMP_API_KEY environment variable "
"or pass api_key parameter."
)
self.session = requests.Session()
self.session.headers.update({"apikey": self.api_key})
self.cache = {}
self.last_call_time = 0
self.rate_limit_reached = False
self.retry_count = 0
self.max_retries = 1
self.api_calls_made = 0
self._endpoint_failures: dict[str, int] = {}
self._disabled_endpoints: set[str] = set()
def _rate_limited_get(
self, url: str, params: Optional[dict] = None, quiet: bool = False
) -> Optional[dict]:
if self.rate_limit_reached:
return None
if params is None:
params = {}
elapsed = time.time() - self.last_call_time
if elapsed < self.RATE_LIMIT_DELAY:
time.sleep(self.RATE_LIMIT_DELAY - elapsed)
try:
response = self.session.get(url, params=params, timeout=30)
self.last_call_time = time.time()
self.api_calls_made += 1
if response.status_code == 200:
self.retry_count = 0
return response.json()
elif response.status_code == 429:
self.retry_count += 1
if self.retry_count <= self.max_retries:
if not quiet:
print(
"WARNING: Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr
)
time.sleep(60)
return self._rate_limited_get(url, params, quiet=quiet)
else:
if not quiet:
print("ERROR: Daily API rate limit reached.", file=sys.stderr)
self.rate_limit_reached = True
return None
else:
if not quiet:
print(
f"ERROR: API request failed: {response.status_code} - {response.text[:200]}",
file=sys.stderr,
)
return None
except requests.exceptions.RequestException as e:
if not quiet:
print(f"ERROR: Request exception: {e}", file=sys.stderr)
return None
def _request_with_fallback(self, endpoint_key, symbols_str, extra_params=None):
"""Try stable endpoint first, fall back to v3 for legacy users."""
params = dict(extra_params) if extra_params else {}
endpoints = _FMP_ENDPOINTS[endpoint_key]
is_single = "," not in symbols_str
for i, (base_url, url_builder) in enumerate(endpoints):
if base_url in self._disabled_endpoints:
continue
url, final_params = url_builder(base_url, symbols_str, dict(params))
is_last = i == len(endpoints) - 1
data = self._rate_limited_get(url, final_params, quiet=not is_last)
if not data:
self._record_endpoint_failure(base_url)
continue
# Normalize new stable EOD flat-list shape to v3-compatible dict.
# No-op for v3 dict / historicalStockList responses.
# `timeseries` (original request) is passed as `limit` so the
# EOD endpoint's full-history response is truncated to the
# legacy "most-recent N rows" contract.
if endpoint_key == "historical":
limit = params.get("timeseries") if isinstance(params, dict) else None
data = _normalize_eod_flat_list(data, symbols_str, limit=limit)
if not data:
self._record_endpoint_failure(base_url)
continue
valid = True
if endpoint_key == "historical":
if not isinstance(data, dict):
valid = False
elif "historicalStockList" in data:
norm = symbols_str.replace("-", ".")
found = None
for entry in data["historicalStockList"]:
if entry.get("symbol", "").replace("-", ".") == norm:
found = {
"symbol": entry.get("symbol"),
"historical": entry.get("historical", []),
}
break
if found:
self._endpoint_failures[base_url] = 0
return found
valid = False
elif "historical" not in data:
valid = False
elif is_single and data.get("symbol"):
if data["symbol"].replace("-", ".") != symbols_str.replace("-", "."):
valid = False
if valid:
self._endpoint_failures[base_url] = 0
return data
self._record_endpoint_failure(base_url)
return None
def _record_endpoint_failure(self, base_url: str) -> None:
failures = self._endpoint_failures.get(base_url, 0) + 1
self._endpoint_failures[base_url] = failures
if failures >= self._ENDPOINT_FAILURE_THRESHOLD:
self._disabled_endpoints.add(base_url)
def get_historical_prices(self, symbol: str, days: int = 600) -> Optional[dict]:
"""Fetch historical daily OHLCV data.
Falls back to yfinance when the FMP historical-price endpoint returns
nothing (e.g. an ETF unavailable on the caller's FMP plan). The yfinance
path requires no extra API key; an FMP API key is still required to
construct this client.
"""
cache_key = f"prices_{symbol}_{days}"
if cache_key in self.cache:
return self.cache[cache_key]
data = self._request_with_fallback("historical", symbol, {"timeseries": days})
# _request_with_fallback can return a truthy dict with an EMPTY
# historical list (v3 `{"symbol":...,"historical":[]}` or an empty
# historicalStockList entry) for ETFs unavailable on the caller's FMP
# plan. A bare `if not data` check would miss that and cache the empty
# result, defeating the fallback's purpose — so treat "no usable
# history" the same as "no data".
if not _has_usable_history(data):
data = self._get_from_yfinance(symbol, days)
if _has_usable_history(data):
self.cache[cache_key] = data
return data
return None
def _get_from_yfinance(self, symbol: str, days: int) -> Optional[dict]:
"""Fallback: fetch ETF history via yfinance when FMP is unavailable.
Returns the same contract as the FMP path
(``{"symbol": ..., "historical": [...]}`` with most-recent-first bars,
each carrying ``date``/``open``/``high``/``low``/``close``/``adjClose``/
``volume``) or ``None`` on empty/error. Never caches here — caching is
the caller's responsibility so a failed lookup leaves no poisoned entry.
``yfinance`` is imported lazily so the FMP success path never depends on
it.
"""
try:
import yfinance as yf
# Request ~1.5x calendar days to cover weekends/holidays.
end = date.today()
start = end - timedelta(days=int(days * 1.5))
df = yf.download(
symbol,
start=start.isoformat(),
end=end.isoformat(),
auto_adjust=True,
progress=False,
)
if df is None or df.empty:
return None
# yfinance returns MultiIndex columns for a single ticker.
if hasattr(df.columns, "levels"):
df.columns = df.columns.droplevel(1)
historical = []
for idx, row in df.iterrows():
close = float(row["Close"])
historical.append(
{
"date": idx.strftime("%Y-%m-%d"),
"open": float(row["Open"]),
"high": float(row["High"]),
"low": float(row["Low"]),
"close": close,
# auto_adjust=True -> Close is already adjusted.
"adjClose": close,
"volume": int(row["Volume"]),
}
)
if not historical:
return None
# yfinance returns ascending; FMP contract is most-recent-first.
historical.reverse()
return {"symbol": symbol, "historical": historical[:days]}
except Exception as e:
print(
f"WARNING: yfinance fallback failed for {symbol}: {e}",
file=sys.stderr,
)
return None
def get_batch_historical(self, symbols: list[str], days: int = 600) -> dict[str, list[dict]]:
"""Fetch historical prices for multiple symbols"""
results = {}
for symbol in symbols:
data = self.get_historical_prices(symbol, days=days)
if data and "historical" in data:
results[symbol] = data["historical"]
return results
def get_treasury_rates(self, days: int = 600) -> Optional[list[dict]]:
"""
Fetch treasury rate data from FMP stable endpoint.
Returns list of dicts with keys like 'date', 'year2', 'year10', etc.
Most recent first.
"""
cache_key = f"treasury_{days}"
if cache_key in self.cache:
return self.cache[cache_key]
url = f"{self.STABLE_URL}/treasury-rates"
params = {"limit": days}
data = self._rate_limited_get(url, params)
if data and isinstance(data, list):
self.cache[cache_key] = data
return data
return None
def get_api_stats(self) -> dict:
return {
"cache_entries": len(self.cache),
"api_calls_made": self.api_calls_made,
"rate_limit_reached": self.rate_limit_reached,
}
#!/usr/bin/env python3
"""
Macro Regime Detector - Main Orchestrator
Detects structural macro regime transitions (1-2 year horizon) using
cross-asset ratio analysis on monthly data.
6 Components:
1. Market Concentration (RSP/SPY) - 25%
2. Yield Curve (10Y-2Y spread) - 20%
3. Credit Conditions (HYG/LQD) - 15%
4. Size Factor (IWM/SPY) - 15%
5. Equity-Bond (SPY/TLT + correlation) - 15%
6. Sector Rotation (XLY/XLP) - 10%
5 Regime Classifications:
- Concentration, Broadening, Contraction, Inflationary, Transitional
Usage:
# With FMP API key in environment:
export FMP_API_KEY=YOUR_KEY
python3 macro_regime_detector.py
# With explicit API key:
python3 macro_regime_detector.py --api-key YOUR_KEY
# Custom output directory:
python3 macro_regime_detector.py --output-dir ./reports
Output:
- JSON: macro_regime_YYYY-MM-DD_HHMMSS.json
- Markdown: macro_regime_YYYY-MM-DD_HHMMSS.md
"""
import argparse
import os
import sys
from datetime import datetime
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(__file__))
from calculators.concentration_calculator import calculate_concentration
from calculators.credit_conditions_calculator import calculate_credit_conditions
from calculators.equity_bond_calculator import calculate_equity_bond
from calculators.sector_rotation_calculator import calculate_sector_rotation
from calculators.size_factor_calculator import calculate_size_factor
from calculators.yield_curve_calculator import calculate_yield_curve
from fmp_client import FMPClient
from report_generator import generate_json_report, generate_markdown_report
from scorer import calculate_composite_score, check_regime_consistency, classify_regime
# ETF symbols needed for analysis
REQUIRED_ETFS = ["RSP", "SPY", "IWM", "TLT", "SHY", "HYG", "LQD", "XLY", "XLP"]
HISTORY_DAYS = 600 # ~2.4 years of daily data
def parse_arguments():
parser = argparse.ArgumentParser(
description="Macro Regime Detector - Cross-Asset Ratio Analysis"
)
parser.add_argument(
"--api-key", help="FMP API key (defaults to FMP_API_KEY environment variable)"
)
parser.add_argument(
"--output-dir",
default=".",
help="Output directory for reports (default: current directory)",
)
parser.add_argument(
"--days",
type=int,
default=HISTORY_DAYS,
help=f"Days of historical data to fetch (default: {HISTORY_DAYS})",
)
return parser.parse_args()
def main():
args = parse_arguments()
print("=" * 70)
print("Macro Regime Detector")
print("Cross-Asset Ratio Analysis for Structural Regime Transitions")
print("=" * 70)
print()
# Initialize FMP client
try:
client = FMPClient(api_key=args.api_key)
print("FMP API client initialized")
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# ================================================================
# Step 1: Fetch Market Data (9 ETFs + Treasury rates = 10 API calls)
# ================================================================
print()
print("Step 1/4: Fetching Market Data")
print("-" * 70)
historical = {}
for etf in REQUIRED_ETFS:
print(f" Fetching {etf} ({args.days} days)...", end=" ", flush=True)
data = client.get_historical_prices(etf, days=args.days)
if data and "historical" in data:
historical[etf] = data["historical"]
print(f"OK ({len(historical[etf])} bars)")
else:
print("FAILED")
historical[etf] = []
# Critical check: need at least SPY
if not historical.get("SPY"):
print("ERROR: Cannot proceed without SPY data", file=sys.stderr)
sys.exit(1)
# Fetch treasury rates
print(" Fetching Treasury rates...", end=" ", flush=True)
treasury_rates = client.get_treasury_rates(days=args.days)
if treasury_rates:
print(f"OK ({len(treasury_rates)} entries)")
else:
print("WARN - Treasury API unavailable, using SHY/TLT fallback")
print()
# ================================================================
# Step 2: Calculate 6 Components
# ================================================================
print("Step 2/4: Calculating Components")
print("-" * 70)
# Component 1: Market Concentration (25%)
print(" [1/6] Market Concentration (RSP/SPY)...", end=" ", flush=True)
comp1 = calculate_concentration(
rsp_history=historical.get("RSP", []),
spy_history=historical.get("SPY", []),
)
print(f"Score: {comp1['score']} ({comp1['signal'][:60]})")
# Component 2: Yield Curve (20%)
print(" [2/6] Yield Curve (10Y-2Y)...", end=" ", flush=True)
comp2 = calculate_yield_curve(
treasury_rates=treasury_rates,
shy_history=historical.get("SHY", []),
tlt_history=historical.get("TLT", []),
)
print(f"Score: {comp2['score']} ({comp2['signal'][:60]})")
# Component 3: Credit Conditions (15%)
print(" [3/6] Credit Conditions (HYG/LQD)...", end=" ", flush=True)
comp3 = calculate_credit_conditions(
hyg_history=historical.get("HYG", []),
lqd_history=historical.get("LQD", []),
)
print(f"Score: {comp3['score']} ({comp3['signal'][:60]})")
# Component 4: Size Factor (15%)
print(" [4/6] Size Factor (IWM/SPY)...", end=" ", flush=True)
comp4 = calculate_size_factor(
iwm_history=historical.get("IWM", []),
spy_history=historical.get("SPY", []),
)
print(f"Score: {comp4['score']} ({comp4['signal'][:60]})")
# Component 5: Equity-Bond Relationship (15%)
print(" [5/6] Equity-Bond (SPY/TLT)...", end=" ", flush=True)
comp5 = calculate_equity_bond(
spy_history=historical.get("SPY", []),
tlt_history=historical.get("TLT", []),
)
print(f"Score: {comp5['score']} ({comp5['signal'][:60]})")
# Component 6: Sector Rotation (10%)
print(" [6/6] Sector Rotation (XLY/XLP)...", end=" ", flush=True)
comp6 = calculate_sector_rotation(
xly_history=historical.get("XLY", []),
xlp_history=historical.get("XLP", []),
)
print(f"Score: {comp6['score']} ({comp6['signal'][:60]})")
print()
# ================================================================
# Step 3: Composite Score & Regime Classification
# ================================================================
print("Step 3/4: Scoring & Classification")
print("-" * 70)
component_scores = {
"concentration": comp1["score"],
"yield_curve": comp2["score"],
"credit_conditions": comp3["score"],
"size_factor": comp4["score"],
"equity_bond": comp5["score"],
"sector_rotation": comp6["score"],
}
data_availability = {
"concentration": comp1.get("data_available", False),
"yield_curve": comp2.get("data_available", False),
"credit_conditions": comp3.get("data_available", False),
"size_factor": comp4.get("data_available", False),
"equity_bond": comp5.get("data_available", False),
"sector_rotation": comp6.get("data_available", False),
}
composite = calculate_composite_score(component_scores, data_availability)
component_results = {
"concentration": comp1,
"yield_curve": comp2,
"credit_conditions": comp3,
"size_factor": comp4,
"equity_bond": comp5,
"sector_rotation": comp6,
}
regime = classify_regime(component_results)
regime["consistency"] = check_regime_consistency(regime["current_regime"], component_results)
print(f" Composite Score: {composite['composite_score']}/100")
print(f" Signal Zone: {composite['zone']}")
print(f" Current Regime: {regime['regime_label']} (confidence: {regime['confidence']})")
print(f" Transition Probability: {regime['transition_probability']['probability_range']}")
print(f" Components Signaling: {composite['signaling_components']}/6")
print()
# ================================================================
# Step 4: Generate Reports
# ================================================================
print("Step 4/4: Generating Reports")
print("-" * 70)
analysis = {
"metadata": {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"data_source": "FMP API",
"history_days": args.days,
"api_calls": client.get_api_stats(),
"etfs_analyzed": REQUIRED_ETFS,
"treasury_data_available": treasury_rates is not None,
},
"composite": composite,
"regime": regime,
"components": component_results,
}
os.makedirs(args.output_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_file = os.path.join(args.output_dir, f"macro_regime_{timestamp}.json")
md_file = os.path.join(args.output_dir, f"macro_regime_{timestamp}.md")
generate_json_report(analysis, json_file)
generate_markdown_report(analysis, md_file)
print()
print("=" * 70)
print("Macro Regime Detection Complete")
print("=" * 70)
print(f" Composite Score: {composite['composite_score']}/100")
print(f" Current Regime: {regime['regime_label']}")
print(f" Transition Probability: {regime['transition_probability']['probability_range']}")
print(f" JSON Report: {json_file}")
print(f" Markdown Report: {md_file}")
print()
stats = client.get_api_stats()
print("API Usage:")
print(f" API calls made: {stats['api_calls_made']}")
print(f" Cache entries: {stats['cache_entries']}")
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Macro Regime Detector - Report Generator
Generates JSON and Markdown reports for macro regime detection analysis.
"""
import json
def generate_json_report(analysis: dict, output_file: str):
"""Save full analysis as JSON"""
with open(output_file, "w", encoding="utf-8") as f:
json.dump(analysis, f, indent=2, default=str)
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", {})
regime = analysis.get("regime", {})
components = analysis.get("components", {})
metadata = analysis.get("metadata", {})
score = composite.get("composite_score", 0)
zone = composite.get("zone", "Unknown")
# Header
lines.append("# Macro Regime Detector Report")
lines.append("")
lines.append(f"**Generated:** {metadata.get('generated_at', 'N/A')}")
lines.append(
f"**Data Source:** FMP API ({metadata.get('api_calls', {}).get('api_calls_made', 'N/A')} calls)"
)
lines.append("")
# ================================================================
# Section 1: Current Regime Assessment
# ================================================================
lines.append("---")
lines.append("")
lines.append("## 1. Current Regime Assessment")
lines.append("")
regime_label = regime.get("regime_label", "Unknown")
confidence = regime.get("confidence", "unknown")
transition = regime.get("transition_probability", {})
zone_emoji = _zone_emoji(composite.get("zone_color", ""))
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| **Current Regime** | **{regime_label}** |")
lines.append(f"| **Classification Confidence** | {confidence.upper()} |")
lines.append(f"| **Transition Score** | {zone_emoji} **{score}/100** |")
lines.append(f"| **Signal Zone** | {zone} |")
lines.append(
f"| **Transition Probability** | {transition.get('probability_range', 'N/A')} ({transition.get('level', 'N/A')}) |"
)
lines.append(f"| **Components Signaling** | {composite.get('signaling_components', 0)}/6 |")
dq = composite.get("data_quality", {})
if dq:
lines.append(f"| **Data Quality** | {dq.get('label', 'N/A')} |")
if transition.get("ambiguous"):
lines.append(
"| **Destination Clarity** | **AMBIGUOUS** - Multiple regimes show similar evidence |"
)
tied = regime.get("tied_regimes")
if tied:
lines.append(f"| **Competing Regimes** | {' vs '.join(r.capitalize() for r in tied)} |")
lines.append("")
lines.append(
"> *Classification Confidence* measures how certain we are about the current regime"
)
lines.append("> classification (separate from *Transition Probability* shown above).")
lines.append("> *Destination Clarity* (shown when ambiguous) indicates how clearly the")
lines.append("> destination regime can be identified.")
lines.append("")
# Regime description
lines.append(f"> **Regime:** {regime.get('regime_description', '')}")
lines.append("")
lines.append(f"> **Guidance:** {composite.get('guidance', '')}")
# Transition direction
from_r = transition.get("from_regime")
to_r = transition.get("to_regime")
if from_r and to_r:
lines.append(
f"> **Transition Direction:** {from_r.capitalize()} \u2192 {to_r.capitalize()}"
)
lines.append("")
# ================================================================
# Section 2: Transition Signal Dashboard
# ================================================================
lines.append("---")
lines.append("")
lines.append("## 2. Transition Signal Dashboard")
lines.append("")
lines.append(
"| # | Component | Weight | Score | Direction | Fit | Crossover | Momentum (3M ROC) |"
)
lines.append(
"|---|-----------|--------|-------|-----------|----|-----------|--------------------| "
)
component_order = [
"concentration",
"yield_curve",
"credit_conditions",
"size_factor",
"equity_bond",
"sector_rotation",
]
consistency = regime.get("consistency", {})
for i, key in enumerate(component_order, 1):
comp_score = composite.get("component_scores", {}).get(key, {})
comp_detail = components.get(key, {})
score_val = comp_score.get("score", 0)
weight_pct = f"{comp_score.get('weight', 0) * 100:.0f}%"
direction = comp_detail.get("direction", "N/A")
mom_qual = comp_detail.get("momentum_qualifier", "")
if mom_qual and mom_qual != "N/A":
direction = f"{direction} ({mom_qual})"
crossover = comp_detail.get("crossover", {})
cross_type = crossover.get("type", "none")
cross_ago = crossover.get("bars_ago")
roc_3m = comp_detail.get("roc_3m")
bar = _score_bar(score_val)
cross_str = cross_type.replace("_", " ")
if cross_ago is not None:
cross_str += f" ({cross_ago}mo ago)"
roc_str = f"{roc_3m:+.2f}%" if roc_3m is not None else "N/A"
fit = consistency.get(key, "--")
fit_str = "OK" if fit == "consistent" else "CONTRA" if fit == "contradicting" else "--"
lines.append(
f"| {i} | **{comp_score.get('label', key)}** | {weight_pct} | "
f"{bar} {score_val} | {direction} | {fit_str} | {cross_str} | {roc_str} |"
)
lines.append("")
lines.append(
f"**Strongest Signal:** {composite.get('strongest_signal', {}).get('label', 'N/A')} "
f"({composite.get('strongest_signal', {}).get('score', 0)}/100)"
)
lines.append(
f"**Weakest Signal:** {composite.get('weakest_signal', {}).get('label', 'N/A')} "
f"({composite.get('weakest_signal', {}).get('score', 0)}/100)"
)
lines.append("")
# ================================================================
# Section 3: Component Details
# ================================================================
lines.append("---")
lines.append("")
lines.append("## 3. Component Details")
lines.append("")
for i, key in enumerate(component_order, 1):
comp = components.get(key, {})
comp_label = composite.get("component_scores", {}).get(key, {}).get("label", key)
lines.append(f"### {i}. {comp_label}")
lines.append("")
if not comp.get("data_available", False):
lines.append(f"- **Status:** {comp.get('signal', 'No data')}")
lines.append("")
continue
# Common fields
if "current_ratio" in comp and comp["current_ratio"] is not None:
lines.append(f"- **Current Ratio:** {comp['current_ratio']}")
if "current_spread" in comp and comp["current_spread"] is not None:
lines.append(f"- **Current Spread:** {comp['current_spread']:+.3f}%")
if comp.get("current_date"):
lines.append(f"- **As Of:** {comp['current_date']}")
if comp.get("sma_6m") is not None:
lines.append(f"- **6M SMA:** {comp['sma_6m']}")
if comp.get("sma_12m") is not None:
lines.append(f"- **12M SMA:** {comp['sma_12m']}")
if comp.get("roc_3m") is not None:
lines.append(f"- **3M ROC:** {comp['roc_3m']:+.2f}%")
if comp.get("roc_12m") is not None:
lines.append(f"- **12M ROC:** {comp['roc_12m']:+.2f}%")
if comp.get("percentile") is not None:
lines.append(f"- **Percentile:** {comp['percentile']:.1f}%")
# Component-specific fields
if key == "yield_curve":
if comp.get("steepening_type"):
steep_labels = {
"bull_steepener": "Bull (short-end led: 2Y rates declining)",
"bear_steepener": "Bear (long-end led: 10Y rates rising)",
"mixed_steepener": "Mixed (both ends moving)",
}
steep_label = steep_labels.get(comp["steepening_type"], comp["steepening_type"])
lines.append(f"- **Steepening Type:** {steep_label}")
if comp.get("curve_state"):
lines.append(f"- **Curve State:** {comp['curve_state']}")
if comp.get("current_10y") is not None:
lines.append(f"- **10Y Rate:** {comp['current_10y']}%")
if comp.get("current_2y") is not None:
lines.append(f"- **2Y Rate:** {comp['current_2y']}%")
if comp.get("data_source"):
lines.append(f"- **Data Source:** {comp['data_source']}")
if comp.get("data_source") == "shy_tlt_proxy":
lines.append("")
lines.append(
"> **Warning:** Yield curve analysis uses SHY/TLT price ratio as proxy "
"(Treasury API 10Y-2Y spread unavailable). This has reduced resolution "
"for interest rate cycle detection. Interpret with caution."
)
if key == "equity_bond":
if comp.get("correlation_6m") is not None:
lines.append(f"- **6M Correlation:** {comp['correlation_6m']}")
if comp.get("correlation_12m") is not None:
lines.append(f"- **12M Correlation:** {comp['correlation_12m']}")
if comp.get("correlation_regime"):
lines.append(f"- **Correlation Regime:** {comp['correlation_regime']}")
lines.append(f"- **Signal:** {comp.get('signal', 'N/A')}")
lines.append("")
# ================================================================
# Section 4: Regime Classification Evidence
# ================================================================
lines.append("---")
lines.append("")
lines.append("## 4. Regime Classification Evidence")
lines.append("")
# Regime scores table
regime_scores = regime.get("regime_scores", {})
if regime_scores:
lines.append("| Regime | Evidence Score | Match |")
lines.append("|--------|--------------|-------|")
current = regime.get("current_regime", "")
for r_name in [
"concentration",
"broadening",
"contraction",
"inflationary",
"transitional",
]:
r_score = regime_scores.get(r_name, 0)
marker = " **CURRENT**" if r_name == current else ""
lines.append(f"| {r_name.capitalize()} | {r_score} |{marker} |")
lines.append("")
# Evidence list
evidence = regime.get("evidence", [])
if evidence:
lines.append("**Key Signals:**")
lines.append("")
for ev in evidence:
lines.append(
f"- **{ev['component']}** (score {ev['score']}): {ev['direction']} - {ev['signal']}"
)
lines.append("")
if transition.get("ambiguous"):
lines.append(
"> **Note:** Regime classification is ambiguous. Multiple regimes show "
"similar evidence scores. This often indicates a transitional period."
)
lines.append("")
# ================================================================
# Section 5: Portfolio Posture Recommendations
# ================================================================
lines.append("---")
lines.append("")
lines.append("## 5. Portfolio Posture Recommendations")
lines.append("")
lines.append(f"**Current Regime:** {regime_label}")
lines.append(f"**Recommended Posture:** {regime.get('portfolio_posture', 'N/A')}")
lines.append("")
# Competing regime posture (when ambiguous)
tied = regime.get("tied_regimes")
if tied and len(tied) >= 2:
from scorer import REGIME_DESCRIPTIONS
other_regime = tied[1] if tied[0] == regime.get("current_regime") else tied[0]
other_info = REGIME_DESCRIPTIONS.get(other_regime, {})
other_posture = other_info.get("portfolio_posture", "")
if other_posture:
lines.append(
f"**Competing Regime ({other_regime.capitalize()}) Posture:** {other_posture}"
)
lines.append("")
lines.append(
"> **Note:** Regime classification is ambiguous. The recommended posture above "
"may conflict with the competing regime's posture. Consider a blended approach "
"(e.g., barbell strategy) that hedges against both scenarios."
)
lines.append("")
# Duration warning when contraction + inflationary signals co-exist
tied = regime.get("tied_regimes")
if tied and "inflationary" in tied:
lines.append("> **Duration Warning:** Contraction and Inflationary signals are both")
lines.append("> present. Long-duration Treasuries may not provide effective hedging")
lines.append("> when stock-bond correlation is positive. Prefer short-duration")
lines.append("> Treasuries + TIPS until correlation regime shifts to negative.")
lines.append("")
eb = components.get("equity_bond", {})
corr_regime = eb.get("correlation_regime", "unknown")
yc = components.get("yield_curve", {})
steep_type = yc.get("steepening_type")
lines.append("**Duration Conditional Recommendations:**")
lines.append("")
if corr_regime == "positive":
lines.append("- Correlation regime is **positive** -> short-duration Treasuries + TIPS")
elif corr_regime == "negative":
if steep_type == "bull_steepener":
lines.append(
"- Correlation regime is **negative** and steepening is **bull steepener** "
"(short-end led) -> duration extension OK"
)
elif steep_type == "bear_steepener":
lines.append(
"- Correlation regime is **negative** and steepening is **bear steepener** "
"(long-end led) -> cautious, TIPS preferred"
)
else:
lines.append(
"- Correlation regime is **negative** -> duration extension possible, "
"monitor steepening type"
)
else:
lines.append("- Correlation regime is **unknown** -> default to short-duration + TIPS")
lines.append("")
# Actions from zone
actions = composite.get("actions", [])
if actions:
lines.append("**Action Items:**")
lines.append("")
for action in actions:
lines.append(f"- {action}")
lines.append("")
# Confirmation and invalidation conditions
lines.append("**Confirmation Conditions:**")
lines.append("")
lines.append("- 3+ components maintaining signal strength above 40 for 2+ months")
lines.append("- Crossover confirmation in primary indicators (RSP/SPY, IWM/SPY)")
lines.append("- Credit conditions (HYG/LQD) consistent with regime hypothesis")
lines.append("")
lines.append("**Invalidation Conditions:**")
lines.append("")
lines.append("- Signal reversal in 2+ primary components within 1 month")
lines.append("- Composite score dropping below 20 (return to stable)")
lines.append("- Credit conditions sharply contradicting regime thesis")
lines.append("")
# Transition triggers (only when ambiguous/transitional/tied)
_add_transition_triggers(lines, regime, components)
# ================================================================
# Methodology
# ================================================================
lines.append("---")
lines.append("")
lines.append("## Methodology")
lines.append("")
lines.append(
"This analysis uses **monthly-frequency cross-asset ratio analysis** to detect "
"structural regime transitions over 1-2 year horizons."
)
lines.append("")
lines.append("**6 Components** (each scored 0-100 for transition signal strength):")
lines.append("")
lines.append(
"1. **Market Concentration** (25%): RSP/SPY ratio - mega-cap concentration vs broadening"
)
lines.append("2. **Yield Curve** (20%): 10Y-2Y spread - interest rate cycle transitions")
lines.append("3. **Credit Conditions** (15%): HYG/LQD ratio - credit cycle risk appetite")
lines.append("4. **Size Factor** (15%): IWM/SPY ratio - small vs large cap rotation")
lines.append("5. **Equity-Bond** (15%): SPY/TLT ratio + correlation - stock-bond regime")
lines.append("6. **Sector Rotation** (10%): XLY/XLP ratio - cyclical vs defensive appetite")
lines.append("")
lines.append("**Transition Detection** uses a 3-layer approach:")
lines.append("")
lines.append("1. MA Crossover: 6-month vs 12-month SMA crossover on each ratio")
lines.append("2. Momentum Shift: 3-month ROC reversing against 12-month trend")
lines.append("3. Cross-Component: Multiple components signaling simultaneously")
lines.append("")
lines.append(
"**5 Regime Classifications:** Concentration, Broadening, Contraction, Inflationary, Transitional"
)
lines.append("")
lines.append("For detailed methodology, see `references/regime_detection_methodology.md`.")
lines.append("")
# Disclaimer
lines.append("---")
lines.append("")
lines.append(
"**Disclaimer:** This analysis is for educational and informational purposes only. "
"Not investment advice. Regime detection is inherently uncertain and signals may "
"produce false positives. Past regime patterns may not predict future transitions. "
"Conduct your own research and consult a financial advisor before making "
"investment decisions."
)
lines.append("")
with open(output_file, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"Markdown report saved to: {output_file}")
def _zone_emoji(color: str) -> str:
mapping = {
"green": "🟢",
"yellow": "🟡",
"orange": "🟠",
"red": "🔴",
"critical": "⚫",
}
return mapping.get(color, "⚪")
def _score_bar(score: int) -> str:
if score >= 80:
return "████"
elif score >= 60:
return "███░"
elif score >= 40:
return "██░░"
elif score >= 20:
return "█░░░"
else:
return "░░░░"
def _add_transition_triggers(lines, regime, components):
"""Generate regime-specific trigger conditions from current component state."""
tied = regime.get("tied_regimes")
if not tied and regime.get("current_regime") != "transitional":
return
lines.append("### Regime Transition Triggers")
lines.append("")
credit_dir = components.get("credit_conditions", {}).get("direction", "unknown")
sector_dir = components.get("sector_rotation", {}).get("direction", "unknown")
yc = components.get("yield_curve", {})
steep_type = yc.get("steepening_type")
lines.append("**Contraction strengthens if:**")
if credit_dir != "tightening":
lines.append("- Credit (HYG/LQD) shifts to tightening")
else:
lines.append("- Credit (HYG/LQD) tightening continues")
if sector_dir != "risk_off":
lines.append("- Sector rotation (XLY/XLP) turns risk-off")
else:
lines.append("- Sector rotation (XLY/XLP) risk-off accelerates")
lines.append("")
lines.append("**Recovery (Broadening) signal if:**")
lines.append("- Credit (HYG/LQD) turns to easing")
lines.append("- Sector rotation (XLY/XLP) turns risk-on")
lines.append("- Concentration (RSP/SPY) shifts to broadening")
lines.append("")
lines.append("**Inflationary regime confirms if:**")
lines.append("- Stock-bond correlation stays positive")
if steep_type:
lines.append("- Yield curve steepening remains bear-type (long-end led)")
else:
lines.append("- Long-term yields continue rising")
lines.append("")
lines.append("**Duration extension OK when:**")
lines.append("- Stock-bond correlation turns negative")
lines.append("- Yield curve shows bull steepener (short-end led)")
lines.append("")
"""Shared fixtures for Macro Regime Detector tests"""
import os
import sys
# Add scripts directory to path so calculators can be imported
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Add tests directory to path so test helpers can be imported
sys.path.insert(0, os.path.dirname(__file__))
"""Tests for Market Concentration Calculator (RSP/SPY)"""
from calculators.concentration_calculator import calculate_concentration
from test_helpers import make_monthly_history
class TestCalculateConcentration:
def test_insufficient_data_empty(self):
result = calculate_concentration([], [])
assert result["score"] == 0
assert result["data_available"] is False
def test_insufficient_data_too_short(self):
rsp = make_monthly_history([100] * 6, start_year=2025)
spy = make_monthly_history([100] * 6, start_year=2025)
result = calculate_concentration(rsp, spy)
assert result["data_available"] is False
def test_stable_ratio_low_score(self):
# Flat ratio = no transition signal
rsp = make_monthly_history([100] * 24, start_year=2024)
spy = make_monthly_history([100] * 24, start_year=2024)
result = calculate_concentration(rsp, spy)
assert result["data_available"] is True
assert result["score"] <= 30 # Small noise from daily variation is expected
assert result["current_ratio"] is not None
def test_rising_rsp_spy_broadening(self):
# RSP rising faster than SPY = broadening
# Create a clear uptrend in RSP relative to SPY
rsp_closes = [100 + i * 2 for i in range(24)] # Rises from 100 to 146
spy_closes = [100 + i * 0.5 for i in range(24)] # Rises from 100 to 111.5
rsp = make_monthly_history(rsp_closes, start_year=2024)
spy = make_monthly_history(spy_closes, start_year=2024)
result = calculate_concentration(rsp, spy)
assert result["data_available"] is True
assert result["monthly_points"] >= 12
def test_declining_rsp_spy_concentrating(self):
# SPY rising faster than RSP = concentrating
rsp_closes = [100 + i * 0.5 for i in range(24)]
spy_closes = [100 + i * 2 for i in range(24)]
rsp = make_monthly_history(rsp_closes, start_year=2024)
spy = make_monthly_history(spy_closes, start_year=2024)
result = calculate_concentration(rsp, spy)
assert result["data_available"] is True
def test_crossover_detected(self):
# Create a ratio that crosses over (death_cross then reverses)
# First 12 months: RSP/SPY declining, next 12 months: recovering
rsp_closes = [120 - i for i in range(12)] + [108 + i * 2 for i in range(12)]
spy_closes = [100] * 24
rsp = make_monthly_history(rsp_closes, start_year=2024)
spy = make_monthly_history(spy_closes, start_year=2024)
result = calculate_concentration(rsp, spy)
assert result["data_available"] is True
assert result["crossover"]["type"] in ("golden_cross", "death_cross", "converging", "none")
def test_output_structure(self):
rsp = make_monthly_history([100 + i for i in range(24)], start_year=2024)
spy = make_monthly_history([100] * 24, start_year=2024)
result = calculate_concentration(rsp, spy)
# Verify required keys
assert "score" in result
assert "signal" in result
assert "data_available" in result
assert "direction" in result
assert "current_ratio" in result
assert "sma_6m" in result
assert "sma_12m" in result
assert "roc_3m" in result
assert "roc_12m" in result
assert "percentile" in result
assert "crossover" in result
assert "monthly_points" in result
assert 0 <= result["score"] <= 100
"""Tests for Credit Conditions Calculator (HYG/LQD)"""
from calculators.credit_conditions_calculator import calculate_credit_conditions
from test_helpers import make_monthly_history
class TestCalculateCreditConditions:
def test_insufficient_data_empty(self):
result = calculate_credit_conditions([], [])
assert result["score"] == 0
assert result["data_available"] is False
def test_stable_ratio_low_score(self):
hyg = make_monthly_history([75] * 24, start_year=2024)
lqd = make_monthly_history([105] * 24, start_year=2024)
result = calculate_credit_conditions(hyg, lqd)
assert result["data_available"] is True
assert result["score"] <= 30 # Small noise from daily variation is expected
def test_easing_conditions(self):
# HYG rising relative to LQD = easing
hyg_closes = [70 + i * 0.5 for i in range(24)]
lqd_closes = [105] * 24
hyg = make_monthly_history(hyg_closes, start_year=2024)
lqd = make_monthly_history(lqd_closes, start_year=2024)
result = calculate_credit_conditions(hyg, lqd)
assert result["data_available"] is True
def test_tightening_conditions(self):
# HYG falling relative to LQD = tightening
hyg_closes = [80 - i * 0.5 for i in range(24)]
lqd_closes = [105] * 24
hyg = make_monthly_history(hyg_closes, start_year=2024)
lqd = make_monthly_history(lqd_closes, start_year=2024)
result = calculate_credit_conditions(hyg, lqd)
assert result["data_available"] is True
def test_output_structure(self):
hyg = make_monthly_history([75 + i * 0.1 for i in range(24)], start_year=2024)
lqd = make_monthly_history([105] * 24, start_year=2024)
result = calculate_credit_conditions(hyg, lqd)
required_keys = [
"score",
"signal",
"data_available",
"direction",
"current_ratio",
"sma_6m",
"sma_12m",
"roc_3m",
"roc_12m",
"percentile",
"crossover",
"monthly_points",
]
for key in required_keys:
assert key in result
assert 0 <= result["score"] <= 100
"""Tests for Equity-Bond Relationship Calculator (SPY/TLT + correlation)"""
from calculators.equity_bond_calculator import calculate_equity_bond
from test_helpers import make_monthly_history
class TestCalculateEquityBond:
def test_insufficient_data_empty(self):
result = calculate_equity_bond([], [])
assert result["score"] == 0
assert result["data_available"] is False
def test_stable_ratio_low_score(self):
spy = make_monthly_history([500] * 24, start_year=2024)
tlt = make_monthly_history([90] * 24, start_year=2024)
result = calculate_equity_bond(spy, tlt)
assert result["data_available"] is True
assert result["score"] <= 30 # Small noise from daily variation is expected
def test_risk_on_shift(self):
# SPY rising, TLT flat = risk-on
spy_closes = [500 + i * 5 for i in range(24)]
tlt_closes = [90] * 24
spy = make_monthly_history(spy_closes, start_year=2024)
tlt = make_monthly_history(tlt_closes, start_year=2024)
result = calculate_equity_bond(spy, tlt)
assert result["data_available"] is True
def test_risk_off_shift(self):
# SPY falling, TLT rising = risk-off
spy_closes = [600 - i * 5 for i in range(24)]
tlt_closes = [80 + i * 2 for i in range(24)]
spy = make_monthly_history(spy_closes, start_year=2024)
tlt = make_monthly_history(tlt_closes, start_year=2024)
result = calculate_equity_bond(spy, tlt)
assert result["data_available"] is True
def test_correlation_regime_present(self):
spy = make_monthly_history([500 + i * 2 for i in range(24)], start_year=2024)
tlt = make_monthly_history([90 - i * 0.5 for i in range(24)], start_year=2024)
result = calculate_equity_bond(spy, tlt)
assert result["correlation_regime"] in (
"negative_strong",
"negative_mild",
"near_zero",
"positive",
"unknown",
)
def test_output_structure(self):
spy = make_monthly_history([500 + i for i in range(24)], start_year=2024)
tlt = make_monthly_history([90] * 24, start_year=2024)
result = calculate_equity_bond(spy, tlt)
required_keys = [
"score",
"signal",
"data_available",
"direction",
"correlation_regime",
"current_ratio",
"sma_6m",
"sma_12m",
"roc_3m",
"roc_12m",
"percentile",
"correlation_6m",
"correlation_12m",
"crossover",
"monthly_points",
]
for key in required_keys:
assert key in result
assert 0 <= result["score"] <= 100
"""Issue #64: stable/historical-price-eod/full normalization for macro-regime-detector."""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from fmp_client import FMPClient
def _make_client():
client = FMPClient(api_key="test_key")
client.max_retries = 0
return client
def _mock_response(status_code, json_payload):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_payload
resp.text = ""
return resp
class TestEODFlatListSuccess:
@patch("fmp_client.requests.Session")
def test_get_historical_prices_normalizes_flat_list(self, mock_session_class):
"""Flat list response -> dict contract preserved."""
mock_session = MagicMock()
mock_session.get.return_value = _mock_response(
200,
[
{
"symbol": "SPY",
"date": "2026-04-29",
"open": 500.0,
"high": 502.0,
"low": 499.0,
"close": 501.0,
"volume": 1_000_000,
},
{
"symbol": "SPY",
"date": "2026-04-28",
"open": 498.0,
"high": 501.0,
"low": 497.0,
"close": 500.0,
"volume": 1_100_000,
},
],
)
mock_session_class.return_value = mock_session
client = _make_client()
client.session = mock_session
result = client.get_historical_prices("SPY", days=2)
assert isinstance(result, dict), f"expected dict, got {type(result).__name__}"
assert result["symbol"] == "SPY"
assert len(result["historical"]) == 2
assert result["historical"][0]["close"] == 501.0
first_call = mock_session.get.call_args_list[0]
url = first_call[0][0]
params = first_call[1]["params"]
assert "historical-price-eod/full" in url
assert "from" in params and "to" in params
assert "timeseries" not in params
class _FakeDF:
"""Minimal stand-in for a yfinance DataFrame (ascending by date)."""
def __init__(self, rows):
self._rows = rows # list of (Timestamp-like, dict)
self.empty = len(rows) == 0
self.columns = _FakeColumns()
def iterrows(self):
return iter(self._rows)
class _FakeColumns:
"""Plain columns object WITHOUT a `levels` attr (no MultiIndex)."""
class _FakeTS:
def __init__(self, iso):
self._iso = iso
def strftime(self, fmt):
# tests only use %Y-%m-%d
return self._iso
def _row(iso, o, h, lo, c, v):
return (
_FakeTS(iso),
{"Open": o, "High": h, "Low": lo, "Close": c, "Volume": v},
)
class TestYFinanceFallback:
"""get_historical_prices falls back to yfinance when FMP returns nothing."""
@patch.object(FMPClient, "_request_with_fallback", return_value=None)
def test_fallback_invoked_and_shape(self, _mock_fmp):
client = _make_client()
fake_df = _FakeDF(
[
_row("2026-04-27", 1.0, 2.0, 0.5, 1.5, 100),
_row("2026-04-28", 1.5, 2.5, 1.0, 2.0, 200),
_row("2026-04-29", 2.0, 3.0, 1.5, 2.5, 300),
]
)
fake_yf = MagicMock()
fake_yf.download.return_value = fake_df
with patch.dict("sys.modules", {"yfinance": fake_yf}):
result = client.get_historical_prices("XLK", days=10)
assert isinstance(result, dict)
assert result["symbol"] == "XLK"
hist = result["historical"]
assert len(hist) == 3
# Most-recent-first (descending) per FMP contract
assert hist[0]["date"] == "2026-04-29"
assert hist[-1]["date"] == "2026-04-27"
bar = hist[0]
for key in ("date", "open", "high", "low", "close", "adjClose", "volume"):
assert key in bar
assert bar["close"] == bar["adjClose"] == 2.5
assert bar["volume"] == 300
@patch.object(FMPClient, "_request_with_fallback", return_value=None)
def test_days_limit_applied(self, _mock_fmp):
client = _make_client()
rows = [_row(f"2026-04-{d:02d}", 1, 2, 0, 1.0 + d, 10 * d) for d in range(1, 11)]
fake_df = _FakeDF(rows)
fake_yf = MagicMock()
fake_yf.download.return_value = fake_df
with patch.dict("sys.modules", {"yfinance": fake_yf}):
result = client.get_historical_prices("XLF", days=3)
assert len(result["historical"]) == 3
# Newest 3 dates kept (descending)
assert result["historical"][0]["date"] == "2026-04-10"
@patch.object(FMPClient, "_request_with_fallback", return_value=None)
def test_empty_df_returns_none_and_not_cached(self, _mock_fmp):
client = _make_client()
fake_yf = MagicMock()
fake_yf.download.return_value = _FakeDF([])
with patch.dict("sys.modules", {"yfinance": fake_yf}):
result = client.get_historical_prices("XLV", days=5)
assert result is None
assert "prices_XLV_5" not in client.cache
@patch.object(FMPClient, "_request_with_fallback", return_value=None)
def test_exception_returns_none_and_not_cached(self, _mock_fmp):
client = _make_client()
fake_yf = MagicMock()
fake_yf.download.side_effect = RuntimeError("network down")
with patch.dict("sys.modules", {"yfinance": fake_yf}):
result = client.get_historical_prices("XLE", days=5)
assert result is None
assert "prices_XLE_5" not in client.cache
@patch.object(FMPClient, "_request_with_fallback")
def test_fmp_success_does_not_call_yfinance(self, mock_fmp):
mock_fmp.return_value = {
"symbol": "SPY",
"historical": [{"date": "2026-04-29", "close": 1.0}],
}
client = _make_client()
fake_yf = MagicMock()
with patch.dict("sys.modules", {"yfinance": fake_yf}):
result = client.get_historical_prices("SPY", days=5)
assert result["symbol"] == "SPY"
fake_yf.download.assert_not_called()
@patch.object(FMPClient, "_request_with_fallback")
def test_empty_historical_dict_triggers_yfinance(self, mock_fmp):
# v3 can return a truthy dict with an EMPTY historical list for an
# ETF unavailable on the caller's plan. Must still fall back.
mock_fmp.return_value = {"symbol": "XLK", "historical": []}
client = _make_client()
fake_df = _FakeDF([_row("2026-04-29", 2.0, 3.0, 1.5, 2.5, 300)])
fake_yf = MagicMock()
fake_yf.download.return_value = fake_df
with patch.dict("sys.modules", {"yfinance": fake_yf}):
result = client.get_historical_prices("XLK", days=5)
fake_yf.download.assert_called_once()
assert result["symbol"] == "XLK"
assert len(result["historical"]) == 1
assert client.cache["prices_XLK_5"] is result
@patch.object(FMPClient, "_request_with_fallback")
def test_empty_historical_then_yfinance_empty_returns_none_not_cached(self, mock_fmp):
mock_fmp.return_value = {"symbol": "XLK", "historical": []}
client = _make_client()
fake_yf = MagicMock()
fake_yf.download.return_value = _FakeDF([])
with patch.dict("sys.modules", {"yfinance": fake_yf}):
result = client.get_historical_prices("XLK", days=5)
assert result is None
assert "prices_XLK_5" not in client.cache
@patch.object(FMPClient, "_request_with_fallback")
def test_empty_historicalstocklist_entry_triggers_yfinance(self, mock_fmp):
# historicalStockList path can yield {"symbol":..., "historical": []}
mock_fmp.return_value = {"symbol": "XLF", "historical": []}
client = _make_client()
fake_df = _FakeDF([_row("2026-04-29", 1.0, 2.0, 0.5, 1.5, 100)])
fake_yf = MagicMock()
fake_yf.download.return_value = fake_df
with patch.dict("sys.modules", {"yfinance": fake_yf}):
result = client.get_historical_prices("XLF", days=5)
fake_yf.download.assert_called_once()
assert result["symbol"] == "XLF"
assert len(result["historical"]) == 1
class TestHasUsableHistory:
"""Unit tests for the _has_usable_history helper."""
def test_none(self):
from fmp_client import _has_usable_history
assert _has_usable_history(None) is False
def test_empty_historical_list(self):
from fmp_client import _has_usable_history
assert _has_usable_history({"symbol": "X", "historical": []}) is False
def test_missing_historical_key(self):
from fmp_client import _has_usable_history
assert _has_usable_history({"symbol": "X"}) is False
def test_non_dict(self):
from fmp_client import _has_usable_history
assert _has_usable_history([{"date": "2026-04-29"}]) is False
def test_non_empty_historical(self):
from fmp_client import _has_usable_history
assert _has_usable_history({"historical": [{"date": "2026-04-29"}]}) is True
Related skills
How it compares
Use macro-regime-detector over single-indicator technical skills when portfolio posture needs a multi-signal historical regime label, not just price momentum.
FAQ
How many regimes does macro-regime-detector classify?
macro-regime-detector maps current readings into five historical macro regime classifications. Each regime includes reference cases with ETF ratios, credit conditions, and documented portfolio postures from past market periods.
Which signals does macro-regime-detector use?
macro-regime-detector analyzes concentration and breadth via ratios such as RSP/SPY and IWM/SPY plus credit spreads through pairs like HYG/LQD, comparing live readings to historical regime examples.
Is Macro Regime Detector safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.