
Ftd Detector
- 939 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
ftd-detector is a trading agent skill that detects Follow-Through Day signals and confirms market bottoms using William O'Neil methodology for developers and quantitative traders who need systematic re-entry timing after
About
ftd-detector is a Claude trading skill that implements William O'Neil Follow-Through Day detection to confirm market bottoms and guide equity re-entry timing. The skill tracks dual indexes—S&P 500 and NASDAQ—through a state machine covering rally attempts, FTD qualification, and post-FTD health monitoring. Developers and systematic traders invoke ftd-detector when evaluating whether corrections have ended and increasing equity exposure is justified. It complements defensive market-top-detector skills by focusing on offensive bottom-confirmation signals. Reach for ftd-detector when analyzing rally attempts, follow-through days, or post-correction re-entry decisions rather than discretionary chart reading alone.
- Dual-index tracking of S&P 500 and NASDAQ
- State machine for rally attempt, FTD qualification, and post-FTD health monitoring
- Generates 0-100 quality score with clear exposure guidance
- Complementary to market-top-detector: offensive bottom confirmation versus defensive top detection
- Triggers on queries about market bottoms, re-entry timing, or increasing equity exposure after corrections
Ftd Detector by the numbers
- 939 all-time installs (skills.sh)
- +83 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #163 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill ftd-detectorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 939 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you detect Follow-Through Day market bottom signals?
Automatically detect Follow-Through Day signals and confirm market bottoms using William O'Neil’s methodology before making equity exposure decisions.
Who is it for?
Systematic equity traders applying William O'Neil FTD rules who want dual-index bottom confirmation before adding exposure.
Skip if: Developers building unrelated application features or traders who rely purely on discretionary analysis without O'Neil methodology.
When should I use this skill?
The user asks about market bottom signals, follow-through days, rally attempts, or safe timing to increase equity exposure after corrections.
What you get
FTD qualification status, dual-index rally-attempt state, post-FTD health readings, and re-entry timing guidance.
- FTD signal assessment
- Dual-index state machine status
- Equity re-entry timing guidance
Files
FTD Detector Skill
Purpose
Detect Follow-Through Day (FTD) signals that confirm a market bottom, using William O'Neil's proven methodology. Generates a quality score (0-100) with exposure guidance for re-entering the market after corrections.
Complementary to Market Top Detector:
- Market Top Detector = defensive (detects distribution, rotation, deterioration)
- FTD Detector = offensive (detects rally attempts, bottom confirmation)
When to Use This Skill
English:
- User asks "Is the market bottoming?" or "Is it safe to buy again?"
- User observes a market correction (3%+ decline) and wants re-entry timing
- User asks about Follow-Through Days or rally attempts
- User wants to assess if a recent bounce is sustainable
- User asks about increasing equity exposure after a correction
- Market Top Detector shows elevated risk and user wants bottom signals
Japanese:
- 「底打ちした?」「買い戻して良い?」
- 調整局面(3%以上の下落)からのエントリータイミング
- フォロースルーデーやラリーアテンプトについて
- 直近の反発が持続可能か評価したい
- 調整後のエクスポージャー拡大の判断
- Market Top Detectorが高リスク表示の後の底打ちシグナル確認
Difference from Market Top Detector
| Aspect | FTD Detector | Market Top Detector |
|---|---|---|
| Focus | Bottom confirmation (offensive) | Top detection (defensive) |
| Trigger | Market correction (3%+ decline) | Market at/near highs |
| Signal | Rally attempt → FTD → Re-entry | Distribution → Deterioration → Exit |
| Score | 0-100 FTD quality | 0-100 top probability |
| Action | When to increase exposure | When to reduce exposure |
---
Execution Workflow
Phase 1: Execute Python Script
Run the FTD detector script:
python3 skills/ftd-detector/scripts/ftd_detector.py --api-key $FMP_API_KEYThe script will: 1. Fetch S&P 500 and QQQ historical data (60+ trading days) from FMP API 2. Fetch current quotes for both indices 3. Run dual-index state machine (correction → rally → FTD detection) 4. Assess post-FTD health (distribution days, invalidation, power trend) 5. Calculate quality score (0-100) 6. Generate JSON and Markdown reports
API Budget: 4 calls (well within free tier of 250/day)
Phase 2: Present Results
Present the generated Markdown report to the user, highlighting:
- Current market state (correction, rally attempt, FTD confirmed, etc.)
- Quality score and signal strength
- Recommended exposure level
- Key watch levels (swing low, FTD day low)
- Post-FTD health (distribution days, power trend)
Phase 3: Contextual Guidance
Based on the market state, provide additional guidance:
If FTD Confirmed (score 60+):
- Suggest looking at leading stocks in proper bases
- Reference CANSLIM screener for candidate stocks
- Remind about position sizing and stops
If Rally Attempt (Day 1-3):
- Advise patience, do not buy ahead of FTD
- Suggest building watchlists
If No Correction:
- FTD analysis is not applicable in uptrend
- Redirect to Market Top Detector for defensive signals
---
State Machine
NO_SIGNAL → CORRECTION → RALLY_ATTEMPT → FTD_WINDOW → FTD_CONFIRMED
↑ ↓ ↓ ↓
└── RALLY_FAILED ←─────────────┘ FTD_INVALIDATED| State | Definition |
|---|---|
| NO_SIGNAL | Uptrend, no qualifying correction |
| CORRECTION | 3%+ decline with 3+ down days |
| RALLY_ATTEMPT | Day 1-3 of rally from swing low |
| FTD_WINDOW | Day 4-10, waiting for qualifying FTD |
| FTD_CONFIRMED | Valid FTD signal detected |
| RALLY_FAILED | Rally broke below swing low |
| FTD_INVALIDATED | Close below FTD day's low |
Quality Score (0-100)
| Score | Signal | Exposure |
|---|---|---|
| 80-100 | Strong FTD | 75-100% |
| 60-79 | Moderate FTD | 50-75% |
| 40-59 | Weak FTD | 25-50% |
| <40 | No FTD / Failed | 0-25% |
---
Prerequisites
- FMP API Key: Required. Set
FMP_API_KEYenvironment variable or pass via--api-keyflag. - Python 3.9+: With
requestslibrary installed. - API Budget: 4 calls per execution (well within FMP free tier of 250/day).
Output Files
- JSON:
ftd_detector_YYYY-MM-DD_HHMMSS.json - Markdown:
ftd_detector_YYYY-MM-DD_HHMMSS.md
Reference Documents
skills/ftd-detector/references/ftd_methodology.md
- O'Neil's FTD rules in detail
- Rally attempt mechanics and day counting
- Historical FTD examples (2020 March, 2022 October)
skills/ftd-detector/references/post_ftd_guide.md
- Post-FTD distribution day failure rates
- Power Trend definition and conditions
- Success vs failure pattern comparison
When to Load References
- First use: Load
skills/ftd-detector/references/ftd_methodology.mdfor full understanding - Post-FTD questions: Load
skills/ftd-detector/references/post_ftd_guide.md - Regular execution: References not needed - script handles analysis
Follow-Through Day (FTD) Methodology
Overview
The Follow-Through Day (FTD) is William O'Neil's market timing signal for confirming a new uptrend after a correction. It is the single most important signal for re-entering the market after a decline. Without an FTD, no new bull market or sustained rally has ever begun (per IBD historical analysis).
Key Insight: FTD is a necessary but not sufficient condition. Historically, approximately 25% of FTDs lead to sustained uptrends. The remaining 75% fail, which is why quality scoring and post-FTD monitoring are critical.
---
Step 1: Identify the Correction
Qualifying Correction
A correction must meet these criteria before FTD analysis begins:
- Decline magnitude: Index closes 3% or more below its recent high
- Duration: At least 3 down days during the decline
- Index scope: S&P 500 and/or NASDAQ Composite (either suffices)
Swing Low Definition
The swing low is the lowest closing price during the correction that becomes the reference point for the rally attempt:
- Must be preceded by 3%+ decline from a recent high (within 40 trading days)
- Must have at least 3 down days between the high and the low
- Must be a local minimum (adjacent days have higher closes)
- Multiple swing lows can occur; use the most recent qualifying one
Important: The swing low is determined by closing price, not intraday low.
---
Step 2: Rally Attempt (Day 1-3)
Day 1 Detection
Day 1 marks the beginning of a rally attempt. It occurs on the first qualifying up day after the swing low:
Primary criterion: Close > Previous day's close (up day)
Alternative criterion: Close in the top 50% of the day's price range
- Formula: (Close - Low) / (High - Low) >= 0.50
- This captures days where the market recovers significantly from intraday lows even if it doesn't close above the prior day's close
Day 2-3 Integrity Check
For the rally attempt to remain valid through Day 2-3:
- Close must not breach Day 1's intraday low (not the close, the low)
- This is a strict rule; even a single day closing below Day 1's low invalidates the attempt
- Day 2 and Day 3 do NOT need to be up days; they just cannot close below Day 1's low
Rally Invalidation (Reset)
The rally attempt resets completely if:
1. Any day's close falls below the swing low price → Start over from new potential swing low 2. Day 2 or Day 3 closes below Day 1's intraday low → Wait for new Day 1
When a rally resets, the new lower price may become the new swing low, and the cycle begins again.
---
Step 3: FTD Window (Day 4-10)
FTD Qualification Criteria
A Follow-Through Day must satisfy ALL of these conditions:
1. Day 4-10 of the rally attempt (Day 4-7 is the prime window; Day 8-10 still valid) 2. Price gain >= 1.25% (minimum threshold)
- 1.25-1.49%: Minimum qualifying gain
- 1.50-1.99%: Recommended gain (higher reliability)
- 2.00%+: Strong signal
3. Volume > previous day's volume (mandatory)
- This confirms institutional participation
- Volume does not need to exceed the 50-day average, though it's a positive if it does
Day Counting Rules
- Day 1 = first qualifying up day after swing low
- Day 2, 3, etc. = every subsequent trading day (regardless of whether it's up or down)
- Days count continues as long as the rally is not invalidated
- The FTD itself must be an up day meeting the gain and volume requirements
Prime vs Late Window
Day 4-7 (Prime):
- Historically higher success rate
- Base quality score: 60 points
- Institutional buyers are more likely to have conviction
Day 8-10 (Late):
- Still valid but statistically weaker
- Base quality score: 50 points
- May indicate hesitant institutional buying
After Day 10:
- No longer qualifies as a traditional FTD
- Rally without FTD by Day 10 is a warning sign
- May still develop into an uptrend but reliability drops significantly
---
Step 4: Dual-Index Confirmation
Single-Index FTD
An FTD on either the S&P 500 or NASDAQ is sufficient to trigger the signal. The signal is actionable on a single-index confirmation.
Dual-Index FTD
When both S&P 500 and NASDAQ produce FTDs (within a few days of each other):
- Significantly higher reliability
- Quality score bonus: +15 points
- Indicates broader institutional conviction
- Both growth and value participants are buying
Index Discrepancy
When one index confirms FTD but the other is still in rally attempt or correction:
- FTD is still valid from the confirming index
- Monitor the lagging index for convergence or divergence
- Divergence (one fails while other holds) is a cautionary signal
---
Quality Score Framework
Score Components (0-100)
| Factor | Criteria | Points |
|---|---|---|
| Base (FTD Day) | Day 4-7 | 60 |
| Day 8-10 | 50 | |
| Price Gain | >= 2.0% | +15 |
| >= 1.5% | +10 | |
| >= 1.25% | +5 | |
| Volume vs 50-day Avg | Above average | +10 |
| Below average | +0 | |
| Dual Index Confirm | Both S&P 500 + NASDAQ | +15 |
| Single index | +0 | |
| Post-FTD Health | No distribution (5 days) | +10 |
| Distribution Day 4-5 | -5 | |
| Distribution Day 3 | -15 | |
| Distribution Day 1-2 | -30 |
Interpretation
| Score | Signal | Recommended Exposure |
|---|---|---|
| 80-100 | Strong FTD | 75-100% equity |
| 60-79 | Moderate FTD | 50-75% equity |
| 40-59 | Weak FTD | 25-50% equity |
| < 40 | No FTD / Failed | 0-25% equity |
---
Historical FTD Examples
March 2020 (COVID Crash)
- Swing Low: March 23, 2020 (S&P 500: ~2,237)
- Day 1: March 24, 2020 (+9.4%)
- FTD: April 2, 2020 (Day 8 of rally, +2.3% on higher volume)
- Outcome: Successful - began one of the strongest bull markets in history
- Quality: Moderate (Day 8 = late window, but strong gain and volume)
- Note: Multiple failed rally attempts preceded this successful one
October 2022 (Bear Market Bottom)
- Swing Low: October 13, 2022 (S&P 500: ~3,491)
- Day 1: October 14, 2022 (+2.6%)
- FTD: October 21, 2022 (Day 6, +2.4% on higher volume)
- Outcome: Successful - confirmed the end of the 2022 bear market
- Quality: High (Day 6 = prime window, strong gain, above-avg volume)
June 2022 (Failed FTD)
- Swing Low: June 17, 2022 (S&P 500: ~3,666)
- FTD: Late June 2022
- Outcome: Failed - market made new lows by September 2022
- Lesson: FTD occurred but distribution days followed quickly; market environment (rising rates, inflation) was hostile
December 2018 (Christmas Eve Low)
- Swing Low: December 24, 2018 (S&P 500: ~2,351)
- FTD: January 4, 2019 (Day 6, +3.4% on higher volume)
- Outcome: Successful - powerful rally through 2019
- Quality: Very high (prime window, strong gain, Fed pivot as catalyst)
---
Common Mistakes
1. Buying before FTD confirmation: Acting on Day 1-3 before FTD is confirmed 2. Ignoring volume: A large gain without volume increase is NOT an FTD 3. Counting wrong: Including non-trading days or resetting day count incorrectly 4. Single vs. dual index: Treating single-index FTD as equivalent to dual-index 5. Ignoring post-FTD distribution: Not monitoring for early distribution days 6. FTD ≠ all clear: Treating FTD as guarantee rather than probability shift
Post-FTD Monitoring Guide
Overview
A confirmed Follow-Through Day (FTD) shifts the probability toward a new uptrend, but approximately 75% of FTDs ultimately fail. Post-FTD monitoring is essential for: 1. Confirming the signal is working (exposure increase) 2. Detecting early failure (exposure reduction) 3. Identifying Power Trend confirmation (maximum conviction)
---
Distribution Day Monitoring After FTD
What Is a Post-FTD Distribution Day?
A distribution day after an FTD is defined as:
- Index declines >= 0.2% from the prior day's close
- Volume is higher than the previous day's volume
- Occurs within the first 5 trading days after the FTD
Failure Rate by Distribution Timing
| Distribution Timing | Failure Rate | Quality Score Impact | Action |
|---|---|---|---|
| Day 1 after FTD | ~85% fail | -30 points | Immediately reduce exposure |
| Day 2 after FTD | ~80% fail | -30 points | Reduce to defensive levels |
| Day 3 after FTD | ~65% fail | -15 points | Tighten stops significantly |
| Day 4 after FTD | ~50% fail | -5 points | Moderate caution |
| Day 5 after FTD | ~45% fail | -5 points | Normal monitoring |
| No distribution (5 days) | ~35% fail | +10 points | Increase conviction |
Key Insight: The earlier distribution appears after an FTD, the more likely the FTD will fail. Distribution within the first 2 days is a near-certain failure signal.
Multiple Distribution Days
- 2+ distribution days within 5 days of FTD: ~90% failure rate
- Even if individual days are in the "moderate" timing zone (Day 4-5), accumulation of distribution is bearish
---
FTD Invalidation
Invalidation Criteria
An FTD is formally invalidated when:
- Index closes below the FTD day's intraday low
- This is a hard stop - the FTD signal is no longer valid
What to Do After Invalidation
1. Reduce equity exposure to defensive levels (0-25%) 2. Do NOT try to average down or hold through 3. Wait for a new swing low and fresh rally attempt 4. The previous FTD failure provides no information about the next attempt
Soft Warnings (Not Yet Invalidated)
- Close approaches but doesn't breach FTD low: heightened caution
- Intraday breach but close above: technically valid but weak
- Slow grinding decline toward FTD low: consider preemptive reduction
---
Power Trend Confirmation
Definition
A Power Trend is the strongest bullish condition in O'Neil's framework. It occurs when three conditions are simultaneously true:
1. 21-day EMA > 50-day SMA (short-term momentum above medium-term trend) 2. 50-day SMA slope is positive (rising over the last 5 trading days) 3. Price above 21-day EMA (current price confirming the trend)
Significance
- Power Trend + FTD = highest conviction bottom signal
- Historically, markets in Power Trend have very low probability of immediate failure
- Power Trend typically develops 2-4 weeks after a successful FTD
- Not required for FTD validity, but serves as strong confirmation
Power Trend Conditions Breakdown
| Conditions Met | Interpretation |
|---|---|
| 3/3 | Full Power Trend - maximum conviction |
| 2/3 | Developing trend - monitor for completion |
| 1/3 | No Power Trend - rely on other signals |
| 0/3 | Bearish structure - be cautious despite FTD |
---
FTD Success vs Failure Patterns
Characteristics of Successful FTDs
| Factor | Successful Pattern |
|---|---|
| Day Timing | Day 4-7 (prime window) |
| Gain | 2.0%+ on heavy volume |
| Volume | Above 50-day average |
| Dual Index | Both S&P 500 and NASDAQ confirm |
| Post-FTD | Clean first 3-5 days (no distribution) |
| Leading Stocks | Many breakouts from proper bases |
| Sector Breadth | Multiple sectors participating |
| Catalyst | Identifiable positive catalyst (Fed pivot, earnings surprise) |
| Power Trend | Develops within 2-4 weeks |
Characteristics of Failed FTDs
| Factor | Failure Pattern |
|---|---|
| Day Timing | Day 8-10 (late window) |
| Gain | Minimum qualifying (1.25-1.49%) |
| Volume | Below 50-day average |
| Dual Index | Only one index confirms |
| Post-FTD | Distribution within first 2 days |
| Leading Stocks | Few/no quality breakouts |
| Sector Breadth | Narrow participation (1-2 sectors) |
| Catalyst | No clear catalyst, or hostile macro backdrop |
| Power Trend | Never develops, 50 SMA continues declining |
---
Exposure Management After FTD
Graduated Exposure Model
The O'Neil approach uses progressive exposure increase, not all-at-once buying:
Phase 1: Initial (FTD Day)
- Start at 25% of target exposure
- Buy 1-2 leading stocks breaking out of bases
- Use FTD day's low as initial stop reference
Phase 2: Confirmation (Days 1-5 post-FTD)
- If no distribution: increase to 50% exposure
- Add positions in additional leaders
- Tighten stops on initial positions to breakeven
Phase 3: Acceleration (Days 5-15 post-FTD)
- If trend confirms (clean action, breakouts working): increase to 75%
- Pyramid into winning positions
- Look for Power Trend development
Phase 4: Full Exposure (2-4 weeks post-FTD)
- If Power Trend develops: full 100% exposure
- Focus on strongest leaders
- Normal stop-loss management
Exposure Reduction Triggers
| Trigger | Action |
|---|---|
| Distribution Day 1-2 post-FTD | Cut to 0-25% |
| Distribution Day 3 post-FTD | Cut to 25-50% |
| FTD invalidated | Cut to 0-25% |
| Breakouts failing (stocks reversing after breakout) | Reduce by 25% |
| No quality setups forming | Don't force increase |
---
Interaction with Market Top Detector
The FTD Detector and Market Top Detector are complementary:
During Correction (Top Detector score 60+):
1. Top Detector signals defensive posture 2. FTD Detector watches for bottom signals 3. When FTD confirms, begin transitioning from defensive to offensive
During FTD Confirmed:
1. FTD Detector guides exposure increase 2. Top Detector should show declining score (improving conditions) 3. If Top Detector score remains high despite FTD, exercise extra caution
Signal Conflict Resolution:
- FTD confirmed but Top Detector still 60+: proceed with caution, use smaller position sizes
- FTD confirmed and Top Detector below 40: higher conviction signal
- FTD invalidated: defer to Top Detector for defensive guidance
---
Historical Success Rate Context
Based on IBD historical analysis of FTDs since 1900:
- Overall FTD success rate: ~25% (1 in 4 leads to sustained uptrend)
- FTDs with quality score 80+: ~45-50% success rate
- FTDs with quality score 60-79: ~30-35% success rate
- FTDs with quality score below 60: ~10-15% success rate
The quality scoring system effectively filters the ~75% failure rate down to a more manageable ~50-55% for high-quality signals. Combined with proper stop-loss management, this creates a positive expected value system despite the sub-50% win rate, because winners significantly outperform losers when properly managed.
#!/usr/bin/env python3
# GENERATED by scripts/generate_fmp_client.py — do not edit.
# Source of truth: scripts/fmp_client/ (core_template.py.tmpl, registry.py, extensions/).
# Regenerate: python3 scripts/generate_fmp_client.py
"""
FMP API Client for FTD Detector
Provides rate-limited access to Financial Modeling Prep API endpoints.
Features:
- Rate limiting (0.3s between requests)
- Automatic retry on 429 errors
- Session caching for duplicate requests
- Batch quote 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_quote_url(base, symbols_str, params):
"""stable/quote?symbol=^GSPC"""
params["symbol"] = symbols_str
return base, params
def _v3_quote_url(base, symbols_str, params):
"""api/v3/quote/^GSPC"""
return f"{base}/{symbols_str}", params
def _stable_hist_url(base, symbols_str, params):
"""stable/historical-price-eod/full?symbol=^GSPC&from=...&to=..."""
params["symbol"] = symbols_str
# New stable EOD endpoint ignores `timeseries`; convert to from/to range
# to bound the payload. Use 2x calendar days to cover N trading days
# (trading-day/calendar-day ratio ~252/365 ~0.69, so *2 leaves headroom).
days = params.pop("timeseries", None)
if days is not None:
today = date.today()
params["from"] = (today - timedelta(days=int(days) * 2)).isoformat()
params["to"] = today.isoformat()
return base, params
def _v3_hist_url(base, symbols_str, params):
"""api/v3/historical-price-full/^GSPC?timeseries=80"""
return f"{base}/{symbols_str}", params
_FMP_ENDPOINTS = {
"quote": [
("https://financialmodelingprep.com/stable/quote", _stable_quote_url),
("https://financialmodelingprep.com/api/v3/quote", _v3_quote_url),
],
"historical": [
("https://financialmodelingprep.com/stable/historical-price-eod/full", _stable_hist_url),
("https://financialmodelingprep.com/api/v3/historical-price-full", _v3_hist_url),
],
}
def _normalize_eod_flat_list(data, symbols_str: str, limit: Optional[int] = None):
"""Convert stable/historical-price-eod/full flat list to v3-compatible dict.
Input : [{"symbol": "SPY", "date": "...", "open": ..., ...}, ...]
Output : {"symbol": "SPY", "historical": [{"date": ..., "open": ..., ...}, ...]}
Returns the input unchanged if not a list (passthrough for v3 dict /
historicalStockList responses). Returns None when no row matches the
requested symbol; the caller will record the failure and try the next
endpoint.
If `limit` is provided (the original `timeseries=N` request), the
`historical` list is truncated to the first `limit` entries. The new
EOD endpoint ignores `timeseries` and returns the full available history,
so the caller's date-range bounding plus this truncation together preserve
the legacy "most-recent N rows" contract. Truncation assumes descending
date order, which the FMP EOD endpoint provides (verified live).
Note: empty list ``[]`` does not reach this normalizer because the caller's
``if not data: continue`` falsy check handles it earlier in
``_request_with_fallback``.
"""
if not isinstance(data, list):
return data
if not data:
return None
norm_target = symbols_str.replace("-", ".")
matched_symbol = None
historical = []
for row in data:
if not isinstance(row, dict):
continue
# Be permissive: single-symbol endpoint may omit per-row "symbol".
# Treat missing symbol as belonging to the requested symbols_str.
row_sym = row.get("symbol") or symbols_str
if row_sym.replace("-", ".") != norm_target:
continue
matched_symbol = matched_symbol or row_sym
historical.append({k: v for k, v in row.items() if k != "symbol"})
if not historical:
return None
if limit is not None and limit > 0:
historical = historical[:limit]
return {"symbol": matched_symbol or symbols_str, "historical": historical}
class FMPClient:
"""Client for Financial Modeling Prep API with rate limiting and caching"""
BASE_URL = "https://financialmodelingprep.com/api/v3"
RATE_LIMIT_DELAY = 0.3 # 300ms between requests
_ENDPOINT_FAILURE_THRESHOLD = 3 # disable endpoint after N consecutive failures
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("FMP_API_KEY")
if not self.api_key:
raise ValueError(
"FMP API key required. Set FMP_API_KEY environment variable "
"or pass api_key parameter."
)
self.session = requests.Session()
self.session.headers.update({"apikey": self.api_key})
self.cache = {}
self.last_call_time = 0
self.rate_limit_reached = False
self.retry_count = 0
self.max_retries = 1
self.api_calls_made = 0
# Circuit breaker: track consecutive failures per endpoint URL prefix
self._endpoint_failures: dict[str, int] = {}
self._disabled_endpoints: set[str] = set()
# Most recent transport-level failure reason; set by _rate_limited_get
# so _request_with_fallback can surface suppressed errors even when
# an endpoint was called with quiet=True.
self._last_error: Optional[str] = None
def _rate_limited_get(
self, url: str, params: Optional[dict] = None, quiet: bool = False
) -> Optional[dict]:
self._last_error = None
if self.rate_limit_reached:
self._last_error = "daily rate limit already reached"
return None
if params is None:
params = {}
elapsed = time.time() - self.last_call_time
if elapsed < self.RATE_LIMIT_DELAY:
time.sleep(self.RATE_LIMIT_DELAY - elapsed)
try:
response = self.session.get(url, params=params, timeout=30)
self.last_call_time = time.time()
self.api_calls_made += 1
if response.status_code == 200:
self.retry_count = 0
return response.json()
elif response.status_code == 429:
self.retry_count += 1
if self.retry_count <= self.max_retries:
print("WARNING: Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr)
time.sleep(60)
return self._rate_limited_get(url, params, quiet=quiet)
else:
self._last_error = "HTTP 429 (daily rate limit)"
print("ERROR: Daily API rate limit reached.", file=sys.stderr)
self.rate_limit_reached = True
return None
else:
msg = f"HTTP {response.status_code} - {response.text[:200]}"
self._last_error = msg
if not quiet:
print(
f"ERROR: API request failed: {msg}",
file=sys.stderr,
)
return None
except requests.exceptions.RequestException as e:
self._last_error = f"request exception: {e}"
print(f"ERROR: Request exception: {e}", file=sys.stderr)
return None
def _request_with_fallback(self, endpoint_key, symbols_str, extra_params=None):
"""Try stable endpoint first, fall back to v3 for legacy users.
Returns parsed JSON in v3-compatible shape, or None if all fail.
Non-last endpoints are called with quiet=True so the user isn't
alarmed by an expected stable failure when v3 will catch it — but
when a non-last endpoint DOES fail, a WARN line is emitted explaining
why we're falling back. Otherwise users only see the (often misleading)
last-endpoint error and have no clue what really went wrong.
"""
params = dict(extra_params) if extra_params else {}
endpoints = _FMP_ENDPOINTS[endpoint_key]
is_single = "," not in symbols_str
for i, (base_url, url_builder) in enumerate(endpoints):
# Circuit breaker: skip endpoints with too many consecutive failures
if base_url in self._disabled_endpoints:
continue
url, final_params = url_builder(base_url, symbols_str, dict(params))
is_last = i == len(endpoints) - 1
data = self._rate_limited_get(url, final_params, quiet=not is_last)
if not data: # falsy (None, [], {}) — try next endpoint
self._record_endpoint_failure(base_url)
self._warn_fallback(base_url, is_last, self._last_error)
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)
self._warn_fallback(
base_url,
is_last,
f"response had no rows matching '{symbols_str}'",
)
continue
# Shape validation: reject truthy-but-wrong-shape responses
valid = True
shape_issue: Optional[str] = None
if endpoint_key == "quote":
if not isinstance(data, list) or len(data) == 0:
valid = False
shape_issue = "expected non-empty list"
elif is_single and not any(
q.get("symbol", "").replace("-", ".") == symbols_str.replace("-", ".")
for q in data
):
valid = False
shape_issue = f"requested symbol '{symbols_str}' not in response"
if endpoint_key == "historical":
if not isinstance(data, dict):
valid = False
shape_issue = "expected dict"
elif "historicalStockList" in data:
# stable batch format -> v3 single format (exact match only)
norm = symbols_str.replace("-", ".")
found = None
for entry in data["historicalStockList"]:
if entry.get("symbol", "").replace("-", ".") == norm:
found = {
"symbol": entry.get("symbol"),
"historical": entry.get("historical", []),
}
break
if found:
self._endpoint_failures[base_url] = 0
return found
valid = False
shape_issue = f"'{symbols_str}' not in historicalStockList"
elif "historical" not in data:
valid = False
shape_issue = "missing 'historical' key"
elif is_single and data.get("symbol"):
if data["symbol"].replace("-", ".") != symbols_str.replace("-", "."):
valid = False
shape_issue = (
f"response symbol '{data['symbol']}' != requested '{symbols_str}'"
)
if valid:
self._endpoint_failures[base_url] = 0
return data
self._record_endpoint_failure(base_url)
self._warn_fallback(base_url, is_last, shape_issue or "unexpected response shape")
return None
def _warn_fallback(self, base_url: str, is_last: bool, reason: Optional[str]) -> None:
"""Emit a WARN line so users see why a non-last endpoint failed and the
client is falling back. No-op when the failing endpoint is the last one
(its error was already printed by _rate_limited_get with quiet=False)."""
if is_last or not reason:
return
print(
f"WARN: {base_url} failed ({reason}); falling back to next endpoint",
file=sys.stderr,
)
def _record_endpoint_failure(self, base_url: str) -> None:
"""Track consecutive failures and disable endpoint after threshold."""
failures = self._endpoint_failures.get(base_url, 0) + 1
self._endpoint_failures[base_url] = failures
if failures >= self._ENDPOINT_FAILURE_THRESHOLD:
self._disabled_endpoints.add(base_url)
def get_quote(self, symbols: str) -> Optional[list[dict]]:
"""Fetch real-time quote data for one or more symbols (comma-separated)"""
cache_key = f"quote_{symbols}"
if cache_key in self.cache:
return self.cache[cache_key]
data = self._request_with_fallback("quote", symbols)
if data:
self.cache[cache_key] = data
return data
def get_batch_quotes(self, symbols: list[str]) -> dict[str, dict]:
"""Fetch quotes for a list of symbols, batching up to 5 per request"""
results = {}
batch_size = 5
for i in range(0, len(symbols), batch_size):
batch = symbols[i : i + batch_size]
batch_str = ",".join(batch)
quotes = self.get_quote(batch_str)
if quotes:
for q in quotes:
results[q["symbol"]] = q
return results
def get_batch_historical(self, symbols: list[str], days: int = 50) -> dict[str, list[dict]]:
"""Fetch historical prices for multiple symbols"""
results = {}
for symbol in symbols:
data = self.get_historical_prices(symbol, days=days)
if data and "historical" in data:
results[symbol] = data["historical"]
return results
def calculate_sma(self, prices: list[float], period: int) -> float:
"""Calculate Simple Moving Average from a list of prices (most recent first)"""
if len(prices) < period:
return sum(prices) / len(prices)
return sum(prices[:period]) / period
def calculate_ema(self, prices: list[float], period: int) -> float:
"""Calculate Exponential Moving Average from a list of prices (most recent first)"""
if len(prices) < period:
return sum(prices) / len(prices)
prices_reversed = prices[::-1]
sma = sum(prices_reversed[:period]) / period
ema = sma
k = 2 / (period + 1)
for price in prices_reversed[period:]:
ema = price * k + ema * (1 - k)
return ema
def get_historical_prices(self, symbol: str, days: int = 365) -> Optional[dict]:
"""Fetch historical daily OHLCV data.
Args:
symbol: Stock symbol
days: Number of trading days to fetch
Returns:
Dict with 'symbol' and 'historical' keys, where 'historical' is a
list of price dicts (most-recent-first) with: date, open, high, low,
close, adjClose, volume
"""
cache_key = f"prices_{symbol}_{days}"
if cache_key in self.cache:
return self.cache[cache_key]
data = self._request_with_fallback("historical", symbol, {"timeseries": days})
if data:
self.cache[cache_key] = data
return data
def get_api_stats(self) -> dict:
"""Return API usage statistics."""
return {
"cache_entries": len(self.cache),
"api_calls_made": self.api_calls_made,
"rate_limit_reached": self.rate_limit_reached,
}
#!/usr/bin/env python3
"""
FTD Detector - Main Orchestrator
Detects Follow-Through Day (FTD) signals for market bottom confirmation
using William O'Neil's methodology with dual-index tracking.
Usage:
python3 ftd_detector.py --api-key YOUR_KEY
python3 ftd_detector.py # uses FMP_API_KEY env var
Output:
- JSON: ftd_detector_YYYY-MM-DD_HHMMSS.json
- Markdown: ftd_detector_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 fmp_client import FMPClient
from post_ftd_monitor import assess_post_ftd_health
from rally_tracker import get_market_state
from report_generator import generate_json_report, generate_markdown_report
def parse_arguments():
parser = argparse.ArgumentParser(
description="FTD Detector - Follow-Through Day Bottom Confirmation"
)
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)",
)
return parser.parse_args()
def main():
args = parse_arguments()
print("=" * 70)
print("FTD Detector - Follow-Through Day Bottom Confirmation")
print("O'Neil Rally Attempt + FTD State Machine (Dual Index)")
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 (4 API calls)
# ========================================================================
print()
print("Step 1: Fetching Market Data")
print("-" * 70)
# S&P 500 history (60 trading days)
print(" Fetching S&P 500 history...", end=" ", flush=True)
sp500_history_data = client.get_historical_prices("^GSPC", days=80)
sp500_history = sp500_history_data.get("historical", []) if sp500_history_data else []
if sp500_history:
print(f"OK ({len(sp500_history)} days)")
else:
print("FAILED")
print("ERROR: Cannot proceed without S&P 500 data", file=sys.stderr)
sys.exit(1)
# NASDAQ/QQQ history (60 trading days)
print(" Fetching NASDAQ (QQQ) history...", end=" ", flush=True)
qqq_history_data = client.get_historical_prices("QQQ", days=80)
qqq_history = qqq_history_data.get("historical", []) if qqq_history_data else []
if qqq_history:
print(f"OK ({len(qqq_history)} days)")
else:
print("WARN - NASDAQ data unavailable, using S&P 500 only")
# S&P 500 quote (for current price)
print(" Fetching S&P 500 quote...", end=" ", flush=True)
sp500_quote_list = client.get_quote("^GSPC")
sp500_quote = sp500_quote_list[0] if sp500_quote_list else None
if sp500_quote:
print(f"OK (${sp500_quote.get('price', 0):.2f})")
else:
print("WARN - Using historical close as current price")
# QQQ quote
print(" Fetching QQQ quote...", end=" ", flush=True)
qqq_quote_list = client.get_quote("QQQ")
qqq_quote = qqq_quote_list[0] if qqq_quote_list else None
if qqq_quote:
print(f"OK (${qqq_quote.get('price', 0):.2f})")
else:
print("WARN - Using historical close as current price")
print()
# ========================================================================
# Step 2: Run State Machine (Rally Tracker)
# ========================================================================
print("Step 2: Analyzing Market State")
print("-" * 70)
market_state = get_market_state(sp500_history, qqq_history)
sp500_state = market_state["sp500"]["state"]
nasdaq_state = market_state["nasdaq"]["state"]
combined = market_state["combined_state"]
print(f" S&P 500 State: {sp500_state}")
print(f" NASDAQ State: {nasdaq_state}")
print(f" Combined: {combined}")
# Print swing low info if found
for label, idx_data in [("S&P 500", market_state["sp500"]), ("NASDAQ", market_state["nasdaq"])]:
swing = idx_data.get("swing_low")
if swing:
print(
f" {label} Swing Low: {swing['swing_low_date']} "
f"(${swing['swing_low_price']:.2f}, "
f"{swing['decline_pct']:.1f}% decline)"
)
rally = idx_data.get("rally_attempt")
if rally and rally.get("day1_date"):
print(f" {label} Rally Day 1: {rally['day1_date']} (Day {rally['current_day_count']})")
print()
# ========================================================================
# Step 3: Post-FTD Health Assessment
# ========================================================================
print("Step 3: Post-FTD Health Assessment")
print("-" * 70)
# Convert to chronological for post-FTD analysis
sp500_chrono = list(reversed(sp500_history))
nasdaq_chrono = list(reversed(qqq_history)) if qqq_history else []
market_state = assess_post_ftd_health(market_state, sp500_chrono, nasdaq_chrono)
quality = market_state.get("quality_score", {})
print(f" Quality Score: {quality.get('total_score', 0)}/100")
print(f" Signal: {quality.get('signal', 'N/A')}")
print(f" Guidance: {quality.get('guidance', 'N/A')}")
print(f" Exposure Range: {quality.get('exposure_range', 'N/A')}")
# Power trend
pt = market_state.get("power_trend", {})
if pt:
print(
f" Power Trend: {'YES' if pt.get('power_trend') else 'No'} "
f"({pt.get('conditions_met', 0)}/3 conditions)"
)
# Post-FTD distribution
dist = market_state.get("post_ftd_distribution", {})
if dist:
print(
f" Post-FTD Distribution Days: {dist.get('distribution_count', 0)} "
f"(monitored {dist.get('days_monitored', 0)} days)"
)
# Invalidation
inv = market_state.get("ftd_invalidation", {})
if inv and inv.get("invalidated"):
print(
f" FTD INVALIDATED on {inv.get('invalidation_date')} "
f"({inv.get('days_after_ftd')} days after FTD)"
)
print()
# ========================================================================
# Step 4: Generate Reports
# ========================================================================
print("Step 4: Generating Reports")
print("-" * 70)
analysis = {
"metadata": {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"api_calls": client.get_api_stats(),
"index_prices": {
"sp500": sp500_quote.get("price", 0)
if sp500_quote
else (sp500_history[0].get("close", 0) if sp500_history else None),
"qqq": qqq_quote.get("price", 0)
if qqq_quote
else (qqq_history[0].get("close", 0) if qqq_history else None),
},
},
"market_state": {
"combined_state": market_state["combined_state"],
"dual_confirmation": market_state["dual_confirmation"],
"ftd_index": market_state.get("ftd_index"),
},
"sp500": _serialize_index(market_state["sp500"]),
"nasdaq": _serialize_index(market_state["nasdaq"]),
"quality_score": quality,
"post_ftd_distribution": market_state.get("post_ftd_distribution", {}),
"ftd_invalidation": market_state.get("ftd_invalidation", {}),
"power_trend": market_state.get("power_trend", {}),
}
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_file = os.path.join(args.output_dir, f"ftd_detector_{timestamp}.json")
md_file = os.path.join(args.output_dir, f"ftd_detector_{timestamp}.md")
generate_json_report(analysis, json_file)
generate_markdown_report(analysis, md_file)
print()
print("=" * 70)
print("FTD Detection Complete")
print("=" * 70)
print(f" Combined State: {market_state['combined_state']}")
print(f" Quality Score: {quality.get('total_score', 0)}/100 ({quality.get('signal', 'N/A')})")
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()
def _serialize_index(idx_data: dict) -> dict:
"""Serialize index analysis for JSON output, removing large rally_days lists."""
result = {
"state": idx_data.get("state"),
"current_price": idx_data.get("current_price"),
"lookback_high": idx_data.get("lookback_high"),
"correction_depth_pct": idx_data.get("correction_depth_pct"),
}
swing = idx_data.get("swing_low")
if swing:
result["swing_low"] = {
"date": swing.get("swing_low_date"),
"price": swing.get("swing_low_price"),
"decline_pct": swing.get("decline_pct"),
"down_days": swing.get("down_days"),
"recent_high_date": swing.get("recent_high_date"),
"recent_high_price": swing.get("recent_high_price"),
}
rally = idx_data.get("rally_attempt")
if rally:
result["rally_attempt"] = {
"day1_date": rally.get("day1_date"),
"current_day_count": rally.get("current_day_count"),
"invalidated": rally.get("invalidated"),
"invalidation_reason": rally.get("invalidation_reason"),
}
ftd = idx_data.get("ftd")
if ftd:
result["ftd"] = {
"ftd_detected": ftd.get("ftd_detected"),
"ftd_date": ftd.get("ftd_date"),
"ftd_day_number": ftd.get("ftd_day_number"),
"ftd_low": ftd.get("ftd_low"),
"gain_pct": ftd.get("gain_pct"),
"gain_tier": ftd.get("gain_tier"),
"volume_above_avg": ftd.get("volume_above_avg"),
}
return result
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
FTD Detector - Post-FTD Health Monitor
Monitors the health of a confirmed Follow-Through Day by tracking:
1. Distribution days after FTD (early distribution = high failure risk)
2. FTD invalidation (close below FTD day's low)
3. Power Trend confirmation (21EMA > 50SMA, slope positive)
4. Quality score calculation (0-100)
"""
def count_post_ftd_distribution(history: list[dict], ftd_idx: int) -> dict:
"""
Count distribution days (down days on higher volume) after FTD.
Distribution within first 1-2 days = very bearish (FTD likely fails)
Distribution on day 3 = moderately bearish
Distribution on day 4-5 = mild negative
Args:
history: Daily OHLCV in chronological order
ftd_idx: Index of the FTD day in history
Returns:
Dict with distribution count, timing, and details
"""
if ftd_idx >= len(history) - 1:
return {
"distribution_count": 0,
"days_monitored": 0,
"earliest_distribution_day": None,
"details": [],
}
n = len(history)
distributions = []
days_monitored = 0
for i in range(ftd_idx + 1, min(ftd_idx + 6, n)): # Monitor up to 5 days post-FTD
days_monitored += 1
curr_close = history[i].get("close", 0)
prev_close = history[i - 1].get("close", 0)
curr_volume = history[i].get("volume", 0)
prev_volume = history[i - 1].get("volume", 0)
if prev_close <= 0 or prev_volume <= 0:
continue
change_pct = (curr_close - prev_close) / prev_close * 100
# Distribution day: down >= 0.2% on higher volume
if change_pct <= -0.2 and curr_volume > prev_volume:
day_num = i - ftd_idx
distributions.append(
{
"day": day_num,
"date": history[i].get("date", "N/A"),
"change_pct": round(change_pct, 2),
"volume_change_pct": round((curr_volume / prev_volume - 1) * 100, 1),
}
)
earliest = distributions[0]["day"] if distributions else None
return {
"distribution_count": len(distributions),
"days_monitored": days_monitored,
"earliest_distribution_day": earliest,
"details": distributions,
}
def check_ftd_invalidation(history: list[dict], ftd_idx: int) -> dict:
"""
Check if FTD has been invalidated by a close below FTD day's low.
Args:
history: Daily OHLCV in chronological order
ftd_idx: Index of the FTD day in history
Returns:
Dict with invalidated flag and details
"""
ftd_low = history[ftd_idx].get("low", history[ftd_idx].get("close", 0))
n = len(history)
for i in range(ftd_idx + 1, n):
curr_close = history[i].get("close", 0)
if curr_close < ftd_low:
return {
"invalidated": True,
"invalidation_date": history[i].get("date", "N/A"),
"invalidation_close": curr_close,
"ftd_low": ftd_low,
"days_after_ftd": i - ftd_idx,
}
return {
"invalidated": False,
"ftd_low": ftd_low,
"days_since_ftd": n - 1 - ftd_idx,
}
def detect_power_trend(history: list[dict]) -> dict:
"""
Detect Power Trend confirmation signals.
Power Trend conditions:
1. 21-day EMA > 50-day SMA
2. 50-day SMA slope is positive (rising over last 5 days)
3. Price above 21-day EMA
Args:
history: Daily OHLCV in chronological order (need 50+ days)
Returns:
Dict with power_trend flag and component checks
"""
if len(history) < 50:
return {
"power_trend": False,
"reason": "Insufficient data (need 50+ days)",
"ema_21": None,
"sma_50": None,
"price_above_21ema": None,
"sma_50_rising": None,
}
closes = [d.get("close", 0) for d in history]
# Calculate 21-day EMA (using most recent data)
ema_21 = _calculate_ema(closes, 21)
# Calculate 50-day SMA (current)
sma_50_current = sum(closes[-50:]) / 50
# Calculate 50-day SMA from 5 days ago
if len(closes) >= 55:
sma_50_5d_ago = sum(closes[-55:-5]) / 50
sma_50_rising = sma_50_current > sma_50_5d_ago
else:
sma_50_rising = None
current_price = closes[-1]
price_above_21ema = current_price > ema_21
ema_above_sma = ema_21 > sma_50_current
power_trend = ema_above_sma and (sma_50_rising is True) and price_above_21ema
conditions_met = sum(
[
ema_above_sma,
sma_50_rising is True,
price_above_21ema,
]
)
return {
"power_trend": power_trend,
"conditions_met": conditions_met,
"ema_21": round(ema_21, 2),
"sma_50": round(sma_50_current, 2),
"current_price": round(current_price, 2),
"ema_above_sma": ema_above_sma,
"sma_50_rising": sma_50_rising,
"price_above_21ema": price_above_21ema,
}
def calculate_ftd_quality_score(market_state: dict) -> dict:
"""
Calculate FTD quality score (0-100) based on multiple factors.
Scoring:
- Base (FTD Day): Day 4-7 = 60pts, Day 8-10 = 50pts
- Price Gain: >=2.0% = +15, >=1.5% = +10, >=1.25% = +5
- Volume vs 50-day avg: Above = +10, Below = +0
- Dual Index Confirm: Both = +15, Single = +0
- Post-FTD Health (Day 1-5): No dist = +10, Dist Day 4-5 = -5,
Dist Day 3 = -15, Dist Day 1-2 = -30
Args:
market_state: Output from get_market_state() with post-FTD analysis
Returns:
Dict with total_score, breakdown, signal, and guidance
"""
score = 0
breakdown = {}
# Find the primary FTD data
sp500 = market_state.get("sp500", {})
nasdaq = market_state.get("nasdaq", {})
dual = market_state.get("dual_confirmation", False)
# Use whichever index confirmed FTD
ftd_data = None
ftd_source = None
for label, idx_data in [("S&P 500", sp500), ("NASDAQ", nasdaq)]:
ftd = idx_data.get("ftd", {})
if ftd and ftd.get("ftd_detected"):
ftd_data = ftd
ftd_source = label
break
if ftd_data is None:
return {
"total_score": 0,
"breakdown": {},
"signal": "No FTD",
"guidance": "No Follow-Through Day detected. Stay defensive.",
"exposure_range": "0-25%",
}
# 1. Base score from FTD day number
day_num = ftd_data.get("ftd_day_number", 0)
if 4 <= day_num <= 7:
base = 60
breakdown["base"] = f"Day {day_num} FTD: +60 (prime window)"
elif 8 <= day_num <= 10:
base = 50
breakdown["base"] = f"Day {day_num} FTD: +50 (late window)"
else:
base = 40
breakdown["base"] = f"Day {day_num} FTD: +40 (out of window)"
score += base
# 2. Price gain bonus
gain = ftd_data.get("gain_pct", 0)
if gain >= FTD_GAIN_STRONG:
gain_bonus = 15
breakdown["gain"] = f"{gain:+.2f}% gain: +15 (strong)"
elif gain >= FTD_GAIN_RECOMMENDED:
gain_bonus = 10
breakdown["gain"] = f"{gain:+.2f}% gain: +10 (recommended)"
elif gain >= FTD_GAIN_MINIMUM:
gain_bonus = 5
breakdown["gain"] = f"{gain:+.2f}% gain: +5 (minimum)"
else:
gain_bonus = 0
breakdown["gain"] = f"{gain:+.2f}% gain: +0"
score += gain_bonus
# 3. Volume vs 50-day average
vol_above = ftd_data.get("volume_above_avg")
if vol_above is True:
vol_bonus = 10
breakdown["volume"] = "Above 50-day avg volume: +10"
elif vol_above is False:
vol_bonus = 0
breakdown["volume"] = "Below 50-day avg volume: +0"
else:
vol_bonus = 0
breakdown["volume"] = "Volume avg data unavailable: +0"
score += vol_bonus
# 4. Dual index confirmation
if dual:
dual_bonus = 15
breakdown["dual_confirm"] = "Both S&P 500 + NASDAQ confirmed: +15"
else:
dual_bonus = 0
breakdown["dual_confirm"] = f"Single index ({ftd_source}): +0"
score += dual_bonus
# 5. Post-FTD health (distribution days)
post_ftd = market_state.get("post_ftd_distribution", {})
dist_count = post_ftd.get("distribution_count", 0)
days_monitored = post_ftd.get("days_monitored", 0)
earliest_dist = post_ftd.get("earliest_distribution_day")
if days_monitored == 0:
health_adj = 0
breakdown["post_ftd"] = "Post-FTD data not yet available: +0"
elif dist_count == 0:
health_adj = 10
breakdown["post_ftd"] = f"No post-FTD distribution ({days_monitored} days clean): +10"
elif earliest_dist is not None and earliest_dist <= 2:
health_adj = -30
breakdown["post_ftd"] = f"Distribution on Day {earliest_dist}: -30 (very bearish)"
elif earliest_dist is not None and earliest_dist == 3:
health_adj = -15
breakdown["post_ftd"] = "Distribution on Day 3: -15 (moderately bearish)"
elif earliest_dist is not None and earliest_dist >= 4:
health_adj = -5
breakdown["post_ftd"] = f"Distribution on Day {earliest_dist}: -5 (mild negative)"
else:
health_adj = 0
breakdown["post_ftd"] = "Post-FTD data unavailable: +0"
# Additional penalty for multiple distribution days (per post_ftd_guide.md)
if dist_count >= 2:
health_adj -= 20
breakdown["post_ftd"] += f" + multiple distributions ({dist_count}x): -20"
score += health_adj
# Clamp score
score = max(0, min(100, score))
# FTD invalidation overrides all scoring
inv = market_state.get("ftd_invalidation", {})
if inv.get("invalidated"):
breakdown["invalidation"] = (
f"FTD invalidated on {inv.get('invalidation_date', 'N/A')} "
f"(Day {inv.get('days_after_ftd', '?')})"
)
return {
"total_score": 0,
"breakdown": breakdown,
"signal": "Failed/Invalidated",
"guidance": "FTD invalidated. Reduce exposure, wait for new rally attempt.",
"exposure_range": "0-25%",
"ftd_source": ftd_source,
}
# Determine signal and guidance
if score >= 80:
signal = "Strong FTD"
guidance = "Aggressively increase exposure to 75-100%."
exposure = "75-100%"
elif score >= 60:
signal = "Moderate FTD"
guidance = "Gradually increase exposure to 50-75%."
exposure = "50-75%"
elif score >= 40:
signal = "Weak FTD"
guidance = "Cautious increase to 25-50%, use tight stops."
exposure = "25-50%"
else:
signal = "Failed/Weak"
guidance = "Stay defensive, wait for new signal."
exposure = "0-25%"
return {
"total_score": score,
"breakdown": breakdown,
"signal": signal,
"guidance": guidance,
"exposure_range": exposure,
"ftd_source": ftd_source,
}
def assess_post_ftd_health(
market_state: dict, sp500_history: list[dict], nasdaq_history: list[dict]
) -> dict:
"""
Full post-FTD health assessment including distribution, invalidation,
and power trend.
Args:
market_state: Output from get_market_state()
sp500_history: S&P 500 chronological history
nasdaq_history: NASDAQ chronological history
Returns:
Enriched market_state with post-FTD analysis
"""
# Find which index has the confirmed FTD
for _label, idx_data, hist in [
("sp500", market_state.get("sp500", {}), sp500_history),
("nasdaq", market_state.get("nasdaq", {}), nasdaq_history),
]:
ftd = idx_data.get("ftd", {})
if ftd and ftd.get("ftd_detected") and hist:
# Find FTD index in history
ftd_date = ftd.get("ftd_date")
ftd_idx = None
for i, d in enumerate(hist):
if d.get("date") == ftd_date:
ftd_idx = i
break
if ftd_idx is not None:
# Distribution check
dist = count_post_ftd_distribution(hist, ftd_idx)
market_state["post_ftd_distribution"] = dist
# Invalidation check
inv = check_ftd_invalidation(hist, ftd_idx)
market_state["ftd_invalidation"] = inv
if inv.get("invalidated"):
market_state["combined_state"] = "FTD_INVALIDATED"
break # Use first confirmed FTD index only (matches quality score logic)
# Power trend check (use S&P 500 as primary)
if sp500_history and len(sp500_history) >= 50:
market_state["power_trend"] = detect_power_trend(sp500_history)
else:
market_state["power_trend"] = {
"power_trend": False,
"reason": "Insufficient S&P 500 data",
}
# Calculate quality score
market_state["quality_score"] = calculate_ftd_quality_score(market_state)
return market_state
# --- Helper ---
# Import thresholds from rally_tracker
FTD_GAIN_STRONG = 2.0
FTD_GAIN_RECOMMENDED = 1.5
FTD_GAIN_MINIMUM = 1.25
def _calculate_ema(prices: list[float], period: int) -> float:
"""Calculate EMA from chronological price list."""
if len(prices) < period:
return sum(prices) / len(prices) if prices else 0
sma = sum(prices[:period]) / period
ema = sma
k = 2 / (period + 1)
for price in prices[period:]:
ema = price * k + ema * (1 - k)
return ema
#!/usr/bin/env python3
"""
FTD Detector - Rally Tracker (State Machine)
Implements a state machine for tracking market correction → rally attempt → FTD sequence.
Supports dual-index tracking (S&P 500 + NASDAQ/QQQ).
States:
NO_SIGNAL → CORRECTION → RALLY_ATTEMPT → FTD_WINDOW → FTD_CONFIRMED
↑ ↓ ↓ ↓
└── RALLY_FAILED ←─────────────┘ FTD_INVALIDATED
O'Neil's FTD Rules:
- Swing low: 3%+ decline from recent high with 3+ down days
- Day 1: first up close (or close in top 50% of range) after swing low
- Day 2-3: close must not breach Day 1 intraday low
- Day 4-10: FTD requires >=1.25% gain on volume > previous day
"""
from enum import Enum
from typing import Optional
class MarketState(Enum):
NO_SIGNAL = "NO_SIGNAL"
CORRECTION = "CORRECTION"
RALLY_ATTEMPT = "RALLY_ATTEMPT"
FTD_WINDOW = "FTD_WINDOW"
FTD_CONFIRMED = "FTD_CONFIRMED"
RALLY_FAILED = "RALLY_FAILED"
FTD_INVALIDATED = "FTD_INVALIDATED"
# Minimum correction depth to qualify
MIN_CORRECTION_PCT = 3.0
# Minimum down days during correction
MIN_DOWN_DAYS = 3
# FTD window bounds (inclusive)
FTD_DAY_START = 4
FTD_DAY_END = 10
# Minimum FTD gain thresholds
FTD_GAIN_MINIMUM = 1.25
FTD_GAIN_RECOMMENDED = 1.5
FTD_GAIN_STRONG = 2.0
def _is_swing_low(history: list[dict], i: int) -> Optional[dict]:
"""Check if index i in history qualifies as a swing low.
Returns dict with swing low details, or None.
"""
n = len(history)
low_close = history[i].get("close", 0)
if low_close <= 0:
return None
# Look back up to 40 days for a recent high
search_start = max(0, i - 40)
recent_high = 0
recent_high_idx = search_start
for j in range(search_start, i):
c = history[j].get("close", 0)
if c > recent_high:
recent_high = c
recent_high_idx = j
if recent_high <= 0:
return None
decline_pct = (low_close - recent_high) / recent_high * 100
if decline_pct > -MIN_CORRECTION_PCT:
return None
# Count down days from high to this point
down_days = 0
for j in range(recent_high_idx + 1, i + 1):
prev_c = history[j - 1].get("close", 0)
curr_c = history[j].get("close", 0)
if prev_c > 0 and curr_c < prev_c:
down_days += 1
if down_days < MIN_DOWN_DAYS:
return None
# Verify it's a local minimum (not lower closes immediately adjacent)
if i > 0:
prev_close = history[i - 1].get("close", 0)
if prev_close > 0 and prev_close < low_close:
return None
if i + 1 < n:
next_close = history[i + 1].get("close", 0)
if next_close > 0 and next_close < low_close:
return None
return {
"swing_low_idx": i,
"swing_low_price": low_close,
"swing_low_date": history[i].get("date", "N/A"),
"swing_low_low": history[i].get("low", low_close),
"recent_high_price": recent_high,
"recent_high_idx": recent_high_idx,
"recent_high_date": history[recent_high_idx].get("date", "N/A"),
"decline_pct": round(decline_pct, 2),
"down_days": down_days,
}
def find_swing_low(history: list[dict]) -> Optional[dict]:
"""Find the most recent qualifying swing low in chronological history."""
if not history or len(history) < 5:
return None
for i in range(len(history) - 1, 3, -1):
result = _is_swing_low(history, i)
if result:
return result
return None
def _find_all_swing_lows(history: list[dict], max_count: int = 6) -> list[dict]:
"""Find all qualifying swing lows, most recent first (up to max_count)."""
if not history or len(history) < 5:
return []
results = []
for i in range(len(history) - 1, 3, -1):
sl = _is_swing_low(history, i)
if sl:
results.append(sl)
if len(results) >= max_count:
break
return results
def track_rally_attempt(history: list[dict], swing_low_idx: int) -> dict:
"""
Track rally attempt starting after swing low.
Day 1: First up close OR close in top 50% of day's range after swing low.
Day 2-3: Close must not breach Day 1 intraday low.
Invalidation: Close below swing low resets the attempt.
Args:
history: Daily OHLCV in chronological order
swing_low_idx: Index of the swing low in history
Returns:
Dict with day1_idx, current_day, rally_days list, invalidated flag, etc.
"""
n = len(history)
swing_low_price = history[swing_low_idx].get("close", 0)
result = {
"day1_idx": None,
"day1_date": None,
"day1_low": None,
"current_day_count": 0,
"rally_days": [],
"invalidated": False,
"invalidation_reason": None,
"reset_count": 0,
}
if swing_low_idx >= n - 1:
return result
# Find Day 1
day1_idx = None
for i in range(swing_low_idx + 1, n):
curr_close = history[i].get("close", 0)
prev_close = history[i - 1].get("close", 0)
curr_high = history[i].get("high", curr_close)
curr_low = history[i].get("low", curr_close)
# Check invalidation first: close below swing low
if curr_close < swing_low_price:
result["invalidated"] = True
result["invalidation_reason"] = (
f"Close ${curr_close:.2f} below swing low ${swing_low_price:.2f} "
f"on {history[i].get('date', 'N/A')}"
)
return result
# Day 1: up close OR close in top 50% of range
day_range = curr_high - curr_low
if prev_close > 0 and curr_close > prev_close:
day1_idx = i
break
elif day_range > 0:
close_position = (curr_close - curr_low) / day_range
if close_position >= 0.5:
day1_idx = i
break
if day1_idx is None:
return result
day1_low = history[day1_idx].get("low", history[day1_idx].get("close", 0))
result["day1_idx"] = day1_idx
result["day1_date"] = history[day1_idx].get("date", "N/A")
result["day1_low"] = day1_low
# Track days from Day 1 onward
day_count = 1
rally_days = [
{
"day": 1,
"idx": day1_idx,
"date": history[day1_idx].get("date", "N/A"),
"close": history[day1_idx].get("close", 0),
"volume": history[day1_idx].get("volume", 0),
}
]
for i in range(day1_idx + 1, n):
curr_close = history[i].get("close", 0)
prev_close = history[i - 1].get("close", 0)
curr_volume = history[i].get("volume", 0)
# Invalidation: close below swing low
if curr_close < swing_low_price:
result["invalidated"] = True
result["invalidation_reason"] = (
f"Close ${curr_close:.2f} below swing low ${swing_low_price:.2f} "
f"on {history[i].get('date', 'N/A')}"
)
break
# Day 2-3 special check: close must not breach Day 1 intraday low
day_count += 1
if day_count <= 3 and curr_close < day1_low:
result["invalidated"] = True
result["invalidation_reason"] = (
f"Day {day_count} close ${curr_close:.2f} below Day 1 low "
f"${day1_low:.2f} on {history[i].get('date', 'N/A')}"
)
break
change_pct = 0
if prev_close > 0:
change_pct = (curr_close - prev_close) / prev_close * 100
rally_days.append(
{
"day": day_count,
"idx": i,
"date": history[i].get("date", "N/A"),
"close": curr_close,
"volume": curr_volume,
"change_pct": round(change_pct, 2),
"volume_vs_prev": (
round((curr_volume / history[i - 1].get("volume", 1) - 1) * 100, 1)
if history[i - 1].get("volume", 0) > 0
else 0
),
}
)
result["current_day_count"] = day_count
result["rally_days"] = rally_days
return result
def detect_ftd(history: list[dict], rally_data: dict) -> dict:
"""
Detect Follow-Through Day within the FTD window (Day 4-10).
FTD Criteria:
- Day 4-10 of rally attempt
- Price gain >= 1.25% (minimum), 1.5% (recommended), 2.0% (strong)
- Volume > previous day (mandatory)
- Volume > point-in-time 50-day average (bonus, no look-ahead)
Args:
history: Daily OHLCV in chronological order
rally_data: Output from track_rally_attempt()
Returns:
Dict with ftd_detected, ftd_day_number, gain_pct, volume details, etc.
"""
result = {
"ftd_detected": False,
"ftd_day_number": None,
"ftd_date": None,
"ftd_price": None,
"ftd_low": None,
"gain_pct": None,
"volume": None,
"prev_day_volume": None,
"volume_above_avg": None,
"gain_tier": None,
"_ftd_idx": None,
}
if rally_data.get("invalidated"):
return result
rally_days = rally_data.get("rally_days", [])
for day_info in rally_days:
day_num = day_info.get("day", 0)
if day_num < FTD_DAY_START or day_num > FTD_DAY_END:
continue
change_pct = day_info.get("change_pct", 0)
if change_pct < FTD_GAIN_MINIMUM:
continue
# Volume must be higher than previous day
idx = day_info.get("idx", 0)
curr_volume = day_info.get("volume", 0)
prev_volume = history[idx - 1].get("volume", 0) if idx > 0 else 0
if prev_volume <= 0 or curr_volume <= prev_volume:
continue
# FTD detected
if change_pct >= FTD_GAIN_STRONG:
gain_tier = "strong"
elif change_pct >= FTD_GAIN_RECOMMENDED:
gain_tier = "recommended"
else:
gain_tier = "minimum"
# Point-in-time 50-day average volume (no look-ahead)
lookback_bars = history[max(0, idx - 50) : idx]
volumes = [d.get("volume", 0) for d in lookback_bars if d.get("volume", 0) > 0]
pit_avg = sum(volumes) / len(volumes) if volumes else 0
volume_above_avg = curr_volume > pit_avg if pit_avg > 0 else None
result.update(
{
"ftd_detected": True,
"ftd_day_number": day_num,
"ftd_date": day_info.get("date", "N/A"),
"ftd_price": day_info.get("close", 0),
"ftd_low": history[idx].get("low", day_info.get("close", 0)),
"gain_pct": change_pct,
"volume": curr_volume,
"prev_day_volume": prev_volume,
"volume_above_avg": volume_above_avg,
"gain_tier": gain_tier,
"_ftd_idx": idx,
}
)
break # Take the first qualifying FTD
return result
def calculate_avg_volume(history: list[dict], period: int = 50) -> float:
"""Calculate average volume over the specified period (most recent data)."""
if not history:
return 0
volumes = [d.get("volume", 0) for d in history[-period:] if d.get("volume", 0) > 0]
return sum(volumes) / len(volumes) if volumes else 0
def analyze_single_index(history: list[dict], index_name: str) -> dict:
"""
Run full FTD analysis for a single index.
Args:
history: Daily OHLCV in chronological order (oldest first)
index_name: Label (e.g., "S&P 500", "NASDAQ")
Returns:
Complete analysis dict for this index
"""
result = {
"index": index_name,
"state": MarketState.NO_SIGNAL.value,
"swing_low": None,
"rally_attempt": None,
"ftd": None,
"current_price": None,
"lookback_high": None,
"correction_depth_pct": None,
}
if not history or len(history) < 10:
result["error"] = "Insufficient data"
return result
# Use last 60 trading days for analysis
lookback = min(60, len(history))
analysis_window = history[-lookback:]
len(analysis_window)
result["current_price"] = analysis_window[-1].get("close", 0)
# Find the highest close in the window
max_close = 0
for d in analysis_window:
c = d.get("close", 0)
if c > max_close:
max_close = c
result["lookback_high"] = max_close
if max_close > 0 and result["current_price"] > 0:
result["correction_depth_pct"] = round(
(result["current_price"] - max_close) / max_close * 100, 2
)
# Do NOT early-return based on current correction depth.
# A valid FTD may be in progress even if price has recovered near highs.
swing_lows = _find_all_swing_lows(analysis_window)
if not swing_lows:
return result
# ── Step 1: Process most recent swing low to determine current_state ──
most_recent_sl = swing_lows[0]
result["swing_low"] = most_recent_sl
result["state"] = MarketState.CORRECTION.value
rally = track_rally_attempt(analysis_window, most_recent_sl["swing_low_idx"])
result["rally_attempt"] = rally
if rally["invalidated"]:
result["state"] = MarketState.RALLY_FAILED.value
elif rally["day1_idx"] is None:
pass # CORRECTION
else:
day_count = rally["current_day_count"]
if day_count < FTD_DAY_START:
result["state"] = MarketState.RALLY_ATTEMPT.value
else:
result["state"] = MarketState.FTD_WINDOW.value
ftd = detect_ftd(analysis_window, rally)
result["ftd"] = ftd
if ftd["ftd_detected"]:
# Inline invalidation check
ftd_idx = ftd.get("_ftd_idx")
if ftd_idx is not None:
ftd_low = analysis_window[ftd_idx].get(
"low", analysis_window[ftd_idx].get("close", 0)
)
invalidated = any(
analysis_window[j].get("close", 0) < ftd_low
for j in range(ftd_idx + 1, len(analysis_window))
)
if not invalidated:
result["state"] = MarketState.FTD_CONFIRMED.value
else:
result["state"] = MarketState.FTD_INVALIDATED.value
else:
result["state"] = MarketState.FTD_CONFIRMED.value
elif day_count > FTD_DAY_END:
result["state"] = MarketState.RALLY_FAILED.value
# If Step 1 found FTD context (confirmed or invalidated), return immediately
if result["state"] in (
MarketState.FTD_CONFIRMED.value,
MarketState.FTD_INVALIDATED.value,
):
return result
# ── Step 2: Search older swing lows for a valid FTD ──
# Handles post-FTD pullback: new swing low exists but FTD low not breached
current_state = result["state"]
for older_sl in swing_lows[1:]:
older_rally = track_rally_attempt(analysis_window, older_sl["swing_low_idx"])
if older_rally["invalidated"] or older_rally["day1_idx"] is None:
continue
if older_rally["current_day_count"] < FTD_DAY_START:
continue
older_ftd = detect_ftd(analysis_window, older_rally)
if not older_ftd["ftd_detected"]:
continue
# FTD found — inline invalidation check
ftd_idx = older_ftd.get("_ftd_idx")
if ftd_idx is None:
continue
ftd_low = analysis_window[ftd_idx].get("low", analysis_window[ftd_idx].get("close", 0))
invalidated = any(
analysis_window[j].get("close", 0) < ftd_low
for j in range(ftd_idx + 1, len(analysis_window))
)
if not invalidated:
# Valid FTD still active through post-FTD pullback
result["swing_low"] = older_sl
result["rally_attempt"] = older_rally
result["ftd"] = older_ftd
result["state"] = MarketState.FTD_CONFIRMED.value
return result
else:
# Invalidated FTD — STOP (no fallback to even older FTDs)
# If newer swing low has active rally, keep that state
active_states = (
MarketState.RALLY_ATTEMPT.value,
MarketState.FTD_WINDOW.value,
)
if current_state not in active_states:
result["swing_low"] = older_sl
result["rally_attempt"] = older_rally
result["ftd"] = older_ftd
result["state"] = MarketState.FTD_INVALIDATED.value
return result
return result
def get_market_state(sp500_history: list[dict], nasdaq_history: list[dict]) -> dict:
"""
Analyze both indices and produce a merged market state assessment.
Priority logic:
- If either index has FTD_CONFIRMED → overall FTD (single sufficient)
- If both have FTD_CONFIRMED → strong FTD (dual confirmation)
- Otherwise, use the more advanced state
Args:
sp500_history: S&P 500 daily OHLCV, most recent first (API format)
nasdaq_history: NASDAQ/QQQ daily OHLCV, most recent first (API format)
Returns:
Combined market state with both index analyses
"""
# Convert to chronological order (oldest first)
sp500_chrono = list(reversed(sp500_history)) if sp500_history else []
nasdaq_chrono = list(reversed(nasdaq_history)) if nasdaq_history else []
sp500_analysis = analyze_single_index(sp500_chrono, "S&P 500")
nasdaq_analysis = analyze_single_index(nasdaq_chrono, "NASDAQ")
sp500_state = MarketState(sp500_analysis["state"])
nasdaq_state = MarketState(nasdaq_analysis["state"])
# Determine combined state
sp500_ftd = sp500_state == MarketState.FTD_CONFIRMED
nasdaq_ftd = nasdaq_state == MarketState.FTD_CONFIRMED
if sp500_ftd and nasdaq_ftd:
combined_state = MarketState.FTD_CONFIRMED.value
dual_confirmation = True
elif sp500_ftd or nasdaq_ftd:
combined_state = MarketState.FTD_CONFIRMED.value
dual_confirmation = False
else:
# Use the more advanced (hopeful) state
# FTD_INVALIDATED above CORRECTION: more informative than bare correction
state_priority = [
MarketState.FTD_WINDOW,
MarketState.RALLY_ATTEMPT,
MarketState.FTD_INVALIDATED,
MarketState.CORRECTION,
MarketState.RALLY_FAILED,
MarketState.NO_SIGNAL,
]
combined_state = MarketState.NO_SIGNAL.value
for state in state_priority:
if sp500_state == state or nasdaq_state == state:
combined_state = state.value
break
dual_confirmation = False
# Determine which index triggered FTD (confirmed or invalidated)
ftd_index = None
if sp500_ftd:
ftd_index = "S&P 500"
if nasdaq_ftd:
ftd_index = "NASDAQ" if ftd_index is None else "Both"
# Also track invalidated FTD index for reporting
if ftd_index is None and combined_state == MarketState.FTD_INVALIDATED.value:
for label, analysis in [("S&P 500", sp500_analysis), ("NASDAQ", nasdaq_analysis)]:
if analysis.get("state") == MarketState.FTD_INVALIDATED.value:
ftd_index = label
break
return {
"combined_state": combined_state,
"dual_confirmation": dual_confirmation,
"ftd_index": ftd_index,
"sp500": sp500_analysis,
"nasdaq": nasdaq_analysis,
}
#!/usr/bin/env python3
"""
FTD Detector - Report Generator
Generates JSON and Markdown reports for FTD detection analysis.
"""
import json
def generate_json_report(analysis: dict, output_file: str):
"""Save full analysis as JSON."""
with open(output_file, "w") 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 = []
metadata = analysis.get("metadata", {})
ms = analysis.get("market_state", {})
sp500 = analysis.get("sp500", {})
nasdaq = analysis.get("nasdaq", {})
quality = analysis.get("quality_score", {})
post_dist = analysis.get("post_ftd_distribution", {})
inv = analysis.get("ftd_invalidation", {})
pt = analysis.get("power_trend", {})
combined_state = ms.get("combined_state", "UNKNOWN")
total_score = quality.get("total_score", 0)
signal = quality.get("signal", "N/A")
# Header
lines.append("# FTD Detector Report")
lines.append("")
lines.append(f"**Generated:** {metadata.get('generated_at', 'N/A')}")
prices = metadata.get("index_prices", {})
if prices.get("sp500"):
lines.append(f"**S&P 500:** ${prices['sp500']:.2f}")
if prices.get("qqq"):
lines.append(f"**QQQ:** ${prices['qqq']:.2f}")
lines.append("")
# ── Market Timing Status ─────────────────────────────────────────────
lines.append("---")
lines.append("")
lines.append("## Market Timing Status")
lines.append("")
state_emoji = _state_emoji(combined_state)
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| **Current State** | {state_emoji} **{_state_label(combined_state)}** |")
lines.append(f"| **Quality Score** | **{total_score}/100** |")
lines.append(f"| **Signal** | {signal} |")
lines.append(f"| **Exposure Guidance** | {quality.get('exposure_range', 'N/A')} |")
if ms.get("dual_confirmation"):
lines.append("| **Dual Confirmation** | YES (S&P 500 + NASDAQ) |")
elif ms.get("ftd_index"):
lines.append(f"| **FTD Index** | {ms['ftd_index']} |")
lines.append("")
lines.append(f"> **Guidance:** {quality.get('guidance', 'N/A')}")
lines.append("")
# ── Index Status Table ────────────────────────────────────────────────
lines.append("---")
lines.append("")
lines.append("## Index Status")
lines.append("")
lines.append("| Index | State | Current | High | Correction | Swing Low |")
lines.append("|-------|-------|---------|------|------------|-----------|")
for label, data in [("S&P 500", sp500), ("NASDAQ/QQQ", nasdaq)]:
state = data.get("state", "N/A")
current = f"${data['current_price']:.2f}" if data.get("current_price") else "N/A"
high = f"${data['lookback_high']:.2f}" if data.get("lookback_high") else "N/A"
corr = (
f"{data['correction_depth_pct']:.1f}%"
if data.get("correction_depth_pct") is not None
else "N/A"
)
swing = data.get("swing_low", {})
sl_str = f"{swing.get('date', 'N/A')} (${swing.get('price', 0):.2f})" if swing else "None"
lines.append(f"| {label} | {state} | {current} | {high} | {corr} | {sl_str} |")
lines.append("")
# ── Rally Attempt Details ─────────────────────────────────────────────
has_rally = False
for label, data in [("S&P 500", sp500), ("NASDAQ/QQQ", nasdaq)]:
rally = data.get("rally_attempt", {})
if rally and rally.get("day1_date"):
has_rally = True
if has_rally:
lines.append("---")
lines.append("")
lines.append("## Rally Attempt Details")
lines.append("")
for label, data in [("S&P 500", sp500), ("NASDAQ/QQQ", nasdaq)]:
rally = data.get("rally_attempt", {})
swing = data.get("swing_low", {})
if not rally or not rally.get("day1_date"):
continue
lines.append(f"### {label}")
lines.append("")
lines.append(
f"- **Swing Low:** {swing.get('date', 'N/A')} "
f"(${swing.get('price', 0):.2f}, {swing.get('decline_pct', 0):.1f}% decline)"
)
lines.append(f"- **Rally Day 1:** {rally.get('day1_date', 'N/A')}")
lines.append(f"- **Current Day Count:** {rally.get('current_day_count', 0)}")
if rally.get("invalidated"):
lines.append(f"- **INVALIDATED:** {rally.get('invalidation_reason', 'N/A')}")
lines.append("")
# ── FTD Signal ────────────────────────────────────────────────────────
sp_ftd = sp500.get("ftd", {})
nq_ftd = nasdaq.get("ftd", {})
has_ftd = (sp_ftd and sp_ftd.get("ftd_detected")) or (nq_ftd and nq_ftd.get("ftd_detected"))
if has_ftd:
lines.append("---")
lines.append("")
lines.append("## FTD Signal")
lines.append("")
for label, ftd_data in [("S&P 500", sp_ftd), ("NASDAQ/QQQ", nq_ftd)]:
if not ftd_data or not ftd_data.get("ftd_detected"):
continue
lines.append(f"### {label} FTD")
lines.append("")
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| **FTD Date** | {ftd_data.get('ftd_date', 'N/A')} |")
lines.append(f"| **Day Number** | Day {ftd_data.get('ftd_day_number', 'N/A')} |")
lines.append(
f"| **Price Gain** | +{ftd_data.get('gain_pct', 0):.2f}% "
f"({ftd_data.get('gain_tier', 'N/A')}) |"
)
vol_above = ftd_data.get("volume_above_avg")
if vol_above is not None:
vol_str = "Above 50-day avg" if vol_above else "Below 50-day avg"
else:
vol_str = "N/A"
lines.append(f"| **Volume** | {vol_str} |")
lines.append("")
# ── Quality Score Breakdown ───────────────────────────────────────────
if quality.get("breakdown"):
lines.append("---")
lines.append("")
lines.append("## Quality Score Breakdown")
lines.append("")
lines.append(f"**Total: {total_score}/100**")
lines.append("")
lines.append("| Factor | Detail |")
lines.append("|--------|--------|")
for key, detail in quality.get("breakdown", {}).items():
lines.append(f"| {key.replace('_', ' ').title()} | {detail} |")
lines.append("")
# ── Post-FTD Health ───────────────────────────────────────────────────
if post_dist or inv or pt:
lines.append("---")
lines.append("")
lines.append("## Post-FTD Health")
lines.append("")
if post_dist:
dist_count = post_dist.get("distribution_count", 0)
monitored = post_dist.get("days_monitored", 0)
lines.append(
f"- **Distribution Days Since FTD:** {dist_count} (in {monitored} days monitored)"
)
for d in post_dist.get("details", []):
lines.append(
f" - Day {d['day']}: {d['date']} "
f"({d['change_pct']:+.2f}%, vol {d['volume_change_pct']:+.1f}%)"
)
if inv:
if inv.get("invalidated"):
lines.append(
f"- **FTD INVALIDATED** on {inv.get('invalidation_date')} "
f"(Day {inv.get('days_after_ftd')}, close ${inv.get('invalidation_close', 0):.2f} "
f"below FTD low ${inv.get('ftd_low', 0):.2f})"
)
else:
lines.append(
f"- **FTD Valid:** {inv.get('days_since_ftd', 0)} days since FTD "
f"(FTD low: ${inv.get('ftd_low', 0):.2f})"
)
if pt:
pt_status = "YES" if pt.get("power_trend") else "No"
lines.append(
f"- **Power Trend:** {pt_status} ({pt.get('conditions_met', 0)}/3 conditions)"
)
if pt.get("ema_21") is not None:
lines.append(
f" - 21 EMA: ${pt['ema_21']:.2f} "
f"({'>' if pt.get('ema_above_sma') else '<'} 50 SMA: ${pt.get('sma_50', 0):.2f})"
)
lines.append(f" - 50 SMA Rising: {'Yes' if pt.get('sma_50_rising') else 'No'}")
lines.append(
f" - Price above 21 EMA: {'Yes' if pt.get('price_above_21ema') else 'No'}"
)
lines.append("")
# ── Action Guidance ───────────────────────────────────────────────────
lines.append("---")
lines.append("")
lines.append("## Action Guidance")
lines.append("")
exposure = quality.get("exposure_range", "N/A")
lines.append(f"**Recommended Exposure:** {exposure}")
lines.append("")
if combined_state == "FTD_CONFIRMED" and total_score >= 80:
lines.append("- Aggressively increase equity exposure")
lines.append("- Buy leading stocks breaking out of proper bases")
lines.append("- Use FTD day's low as stop-loss reference")
lines.append("- Monitor for distribution days (early distribution = caution)")
elif combined_state == "FTD_CONFIRMED" and total_score >= 60:
lines.append("- Gradually increase exposure with each successful breakout")
lines.append("- Start with half positions, add on confirmation")
lines.append("- Use FTD day's low as invalidation level")
lines.append("- Watch for distribution within 3 days (bearish)")
elif combined_state == "FTD_CONFIRMED":
lines.append("- Cautious exposure increase with tight stops")
lines.append("- Only buy highest-quality setups")
lines.append("- Small position sizes (25-50% of normal)")
lines.append("- Be prepared for FTD failure")
elif combined_state == "FTD_WINDOW":
lines.append("- WATCH MODE: FTD window is open (Day 4-10)")
lines.append("- Prepare buy lists of leading stocks in bases")
lines.append("- Do not buy ahead of FTD confirmation")
lines.append("- Monitor daily for qualifying FTD day")
elif combined_state == "RALLY_ATTEMPT":
lines.append("- Rally attempt in progress (Day 1-3)")
lines.append("- Too early to act - wait for Day 4+")
lines.append("- Prepare watchlists, research leaders")
lines.append("- Monitor for rally failure (close below swing low)")
elif combined_state == "CORRECTION":
lines.append("- Market in correction, no rally attempt yet")
lines.append("- Stay defensive, preserve capital")
lines.append("- Build watchlists of relative strength leaders")
lines.append("- Wait for first up day to start rally count")
elif combined_state == "FTD_INVALIDATED":
lines.append("- FTD has been invalidated - signal failed")
lines.append("- Reduce exposure back to defensive levels")
lines.append("- Wait for new swing low and fresh rally attempt")
lines.append("- Do not try to anticipate next FTD")
elif combined_state == "RALLY_FAILED":
lines.append("- Rally attempt failed (broke below swing low)")
lines.append("- Remain in cash/defensive")
lines.append("- New swing low may form - reset cycle")
lines.append("- Patience is key; do not force entries")
else:
lines.append("- No correction detected - normal market conditions")
lines.append("- FTD monitoring not applicable in uptrend")
lines.append("- Focus on individual stock setups")
lines.append("- Use Market Top Detector for defensive signals")
lines.append("")
# ── Key Watch Levels ──────────────────────────────────────────────────
lines.append("---")
lines.append("")
lines.append("## Key Watch Levels")
lines.append("")
for label, data in [("S&P 500", sp500), ("NASDAQ/QQQ", nasdaq)]:
swing = data.get("swing_low", {})
ftd_data = data.get("ftd", {})
if not swing and not ftd_data:
continue
lines.append(f"**{label}:**")
if swing:
lines.append(f"- Swing Low: ${swing.get('price', 0):.2f} ({swing.get('date', 'N/A')})")
if ftd_data and ftd_data.get("ftd_detected"):
lines.append(f"- FTD Day: {ftd_data.get('ftd_date', 'N/A')}")
if ftd_data.get("ftd_low"):
lines.append(f"- FTD Day Low (invalidation level): ${ftd_data['ftd_low']:.2f}")
if data.get("lookback_high"):
lines.append(f"- Lookback High: ${data['lookback_high']:.2f}")
lines.append("")
# ── Methodology ───────────────────────────────────────────────────────
lines.append("---")
lines.append("")
lines.append("## Methodology")
lines.append("")
lines.append(
"This analysis uses William O'Neil's Follow-Through Day (FTD) methodology "
"to confirm market bottoms:"
)
lines.append("")
lines.append("1. **Swing Low Detection:** 3%+ decline from recent high with 3+ down days")
lines.append("2. **Rally Attempt:** Day 1 (first up close), Day 2-3 must hold Day 1 low")
lines.append("3. **FTD (Day 4-10):** 1.25%+ gain on volume higher than previous day")
lines.append(
"4. **Quality Score:** Multi-factor 0-100 score (day timing, gain size, "
"volume, dual-index, post-FTD health)"
)
lines.append(
"5. **Post-FTD Monitoring:** Distribution day tracking, invalidation check, "
"Power Trend confirmation"
)
lines.append("")
lines.append(
"Dual-index tracking (S&P 500 + NASDAQ) provides stronger confirmation "
"than single-index analysis."
)
lines.append("")
lines.append("For detailed methodology, see `references/ftd_methodology.md`.")
lines.append("")
# ── Disclaimer ────────────────────────────────────────────────────────
lines.append("---")
lines.append("")
lines.append(
"**Disclaimer:** This analysis is for educational and informational purposes only. "
"Not investment advice. Follow-Through Days have approximately a 25% success rate "
"historically. Always use proper risk management and position sizing. "
"Consult a financial advisor before making investment decisions."
)
lines.append("")
with open(output_file, "w") as f:
f.write("\n".join(lines))
print(f" Markdown report saved to: {output_file}")
def _state_emoji(state: str) -> str:
mapping = {
"NO_SIGNAL": "⚪",
"CORRECTION": "🔴",
"RALLY_ATTEMPT": "🟡",
"FTD_WINDOW": "🟡",
"FTD_CONFIRMED": "🟢",
"RALLY_FAILED": "🔴",
"FTD_INVALIDATED": "🔴",
}
return mapping.get(state, "⚪")
def _state_label(state: str) -> str:
mapping = {
"NO_SIGNAL": "No Signal (Uptrend)",
"CORRECTION": "Correction",
"RALLY_ATTEMPT": "Rally Attempt (Day 1-3)",
"FTD_WINDOW": "FTD Window (Day 4-10)",
"FTD_CONFIRMED": "FTD Confirmed",
"RALLY_FAILED": "Rally Failed",
"FTD_INVALIDATED": "FTD Invalidated",
}
return mapping.get(state, state)
"""Shared fixtures for FTD Detector tests"""
import os
import sys
# Add scripts directory to path so modules can be imported
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Add tests directory to path so helpers can be imported
sys.path.insert(0, os.path.dirname(__file__))
"""Importable test helpers for FTD Detector tests"""
def make_bar(close, volume=1_000_000, date="2026-01-15", open_=None, high=None, low=None):
"""Create a single OHLCV bar dict."""
if open_ is None:
open_ = close
if high is None:
high = close * 1.005
if low is None:
low = close * 0.995
return {
"date": date,
"open": open_,
"high": high,
"low": low,
"close": close,
"volume": volume,
}
def make_correction_history(peak=100.0, decline_pct=5.0, down_days=5, base_volume=1_000_000):
"""
Build a synthetic chronological history with a clear correction.
Returns history with: flat lead-in -> peak -> decline -> swing low.
Total length ~ 20 bars (enough for find_swing_low lookback).
"""
bars = []
day = 0
# 10-day flat lead-in near peak
for i in range(10):
price = peak * (0.98 + 0.02 * (i / 10))
bars.append(make_bar(price, base_volume, date=f"day-{day:03d}"))
day += 1
# Peak day
bars.append(make_bar(peak, base_volume, date=f"day-{day:03d}"))
day += 1
# Decline phase: down_days of steady decline to reach target
low_price = peak * (1 - decline_pct / 100)
step = (peak - low_price) / down_days
for i in range(1, down_days + 1):
price = peak - step * i
bars.append(make_bar(price, base_volume, date=f"day-{day:03d}"))
day += 1
return bars, day
def make_rally_history(
peak=100.0,
decline_pct=5.0,
down_days=5,
rally_days=10,
rally_gain_per_day=0.3,
base_volume=1_000_000,
ftd_day=None,
ftd_gain_pct=1.5,
ftd_volume_mult=1.5,
):
"""
Build a full correction -> rally history with optional FTD.
Args:
ftd_day: If set, inject an FTD-qualifying day at this rally day number.
ftd_gain_pct: Gain percentage for the FTD day.
ftd_volume_mult: Volume multiplier for FTD day vs previous day.
Returns (history, swing_low_idx, day1_idx, ftd_day_idx).
"""
bars, day = make_correction_history(peak, decline_pct, down_days, base_volume)
swing_low_idx = len(bars) - 1
swing_low_price = bars[-1]["close"]
day1_idx = None
ftd_day_idx = None
prev_close = swing_low_price
for rally_num in range(1, rally_days + 1):
vol = base_volume
if ftd_day is not None and rally_num == ftd_day:
# FTD day: large gain, higher volume than previous day
gain = ftd_gain_pct / 100
price = prev_close * (1 + gain)
vol = int(base_volume * ftd_volume_mult)
ftd_day_idx = len(bars)
else:
price = prev_close * (1 + rally_gain_per_day / 100)
bars.append(
make_bar(
price,
vol,
date=f"day-{day:03d}",
low=price * 0.997,
)
)
if day1_idx is None and price > prev_close:
day1_idx = len(bars) - 1
prev_close = price
day += 1
return bars, swing_low_idx, day1_idx, ftd_day_idx
"""Tests for FMP client endpoint fallback (stable -> v3).
Tier A: Fallback logic (4 tests)
Tier B: Response normalization (4 tests)
Tier B+: Shape validation (2 tests)
Tier C: Caller regression (2 tests)
"""
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
# Ensure scripts directory is on sys.path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from fmp_client import FMPClient
def _make_client():
"""Create an FMPClient with a fake API key and zero rate-limit delay."""
client = FMPClient(api_key="test_key")
client.RATE_LIMIT_DELAY = 0
return client
def _mock_response(status_code, json_data=None):
"""Create a mock response object."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_data
resp.text = f"HTTP {status_code}"
return resp
# =========================================================================
# Tier A — Fallback logic
# =========================================================================
class TestFallbackLogic:
"""Tier A: stable -> v3 fallback mechanics."""
def test_quote_stable_success(self):
"""Stable 200 returns data; v3 not called."""
client = _make_client()
quote_data = [{"symbol": "^GSPC", "price": 5000.0}]
stable_resp = _mock_response(200, quote_data)
call_count = 0
def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
return stable_resp
client.session.get = MagicMock(side_effect=side_effect)
result = client.get_quote("^GSPC")
assert result == quote_data
# Only stable endpoint called (1 call)
assert call_count == 1
def test_quote_stable_403_falls_back_to_v3(self):
"""Stable 403, v3 200 -> returns v3 data."""
client = _make_client()
quote_data = [{"symbol": "^GSPC", "price": 5000.0}]
stable_resp = _mock_response(403)
v3_resp = _mock_response(200, quote_data)
responses = [stable_resp, v3_resp]
client.session.get = MagicMock(side_effect=responses)
result = client.get_quote("^GSPC")
assert result == quote_data
assert client.session.get.call_count == 2
def test_quote_both_fail(self):
"""Both 403 -> returns None."""
client = _make_client()
stable_resp = _mock_response(403)
v3_resp = _mock_response(403)
client.session.get = MagicMock(side_effect=[stable_resp, v3_resp])
result = client.get_quote("^GSPC")
assert result is None
def test_historical_fallback_to_v3(self):
"""Stable 403, v3 200 -> returns v3 historical data."""
client = _make_client()
hist_data = {
"symbol": "^GSPC",
"historical": [{"date": "2026-03-20", "close": 5000.0}],
}
stable_resp = _mock_response(403)
v3_resp = _mock_response(200, hist_data)
client.session.get = MagicMock(side_effect=[stable_resp, v3_resp])
result = client.get_historical_prices("^GSPC", days=80)
assert result == hist_data
assert client.session.get.call_count == 2
# =========================================================================
# Tier B — Response normalization
# =========================================================================
class TestResponseNormalization:
"""Tier B: historicalStockList normalization and passthrough."""
def test_historical_stable_v3_format_passthrough(self):
"""Stable 200 with {"historical": [...]} -> returned as-is."""
client = _make_client()
hist_data = {
"symbol": "^GSPC",
"historical": [{"date": "2026-03-20", "close": 5000.0}],
}
resp = _mock_response(200, hist_data)
client.session.get = MagicMock(return_value=resp)
result = client.get_historical_prices("^GSPC", days=80)
assert result == hist_data
def test_historical_stable_batch_format_exact_match(self):
"""Stable 200 with historicalStockList matching symbol -> normalized."""
client = _make_client()
batch_data = {
"historicalStockList": [
{
"symbol": "^GSPC",
"historical": [{"date": "2026-03-20", "close": 5000.0}],
}
]
}
resp = _mock_response(200, batch_data)
client.session.get = MagicMock(return_value=resp)
result = client.get_historical_prices("^GSPC", days=80)
assert result is not None
assert "historical" in result
assert result["historical"] == [{"date": "2026-03-20", "close": 5000.0}]
assert result["symbol"] == "^GSPC"
def test_historical_stable_batch_no_match_falls_back_to_v3(self):
"""Stable batch no match -> continues to v3 200."""
client = _make_client()
# Stable returns batch with a different symbol
batch_data = {
"historicalStockList": [
{
"symbol": "SPY",
"historical": [{"date": "2026-03-20", "close": 500.0}],
}
]
}
v3_data = {
"symbol": "^GSPC",
"historical": [{"date": "2026-03-20", "close": 5000.0}],
}
stable_resp = _mock_response(200, batch_data)
v3_resp = _mock_response(200, v3_data)
client.session.get = MagicMock(side_effect=[stable_resp, v3_resp])
result = client.get_historical_prices("^GSPC", days=80)
assert result == v3_data
assert client.session.get.call_count == 2
def test_historical_batch_no_match_returns_none_when_v3_also_fails(self):
"""Stable batch no match + v3 403 -> None."""
client = _make_client()
batch_data = {
"historicalStockList": [
{
"symbol": "SPY",
"historical": [{"date": "2026-03-20", "close": 500.0}],
}
]
}
stable_resp = _mock_response(200, batch_data)
v3_resp = _mock_response(403)
client.session.get = MagicMock(side_effect=[stable_resp, v3_resp])
result = client.get_historical_prices("^GSPC", days=80)
assert result is None
# =========================================================================
# Tier B+ — Shape validation
# =========================================================================
class TestShapeValidation:
"""Tier B+: Reject truthy-but-wrong-shape responses."""
def test_quote_rejects_non_list_response(self):
"""Stable returns truthy dict -> skipped, falls back to v3."""
client = _make_client()
# Stable returns a dict (wrong shape for quote)
error_data = {"Error Message": "Invalid API call"}
v3_data = [{"symbol": "^GSPC", "price": 5000.0}]
stable_resp = _mock_response(200, error_data)
v3_resp = _mock_response(200, v3_data)
client.session.get = MagicMock(side_effect=[stable_resp, v3_resp])
result = client.get_quote("^GSPC")
assert result == v3_data
assert client.session.get.call_count == 2
def test_historical_rejects_non_dict_response(self):
"""Stable returns truthy list -> skipped, falls back to v3."""
client = _make_client()
# Stable returns a list (wrong shape for historical)
wrong_data = [1, 2, 3]
v3_data = {
"symbol": "^GSPC",
"historical": [{"date": "2026-03-20", "close": 5000.0}],
}
stable_resp = _mock_response(200, wrong_data)
v3_resp = _mock_response(200, v3_data)
client.session.get = MagicMock(side_effect=[stable_resp, v3_resp])
result = client.get_historical_prices("^GSPC", days=80)
assert result == v3_data
assert client.session.get.call_count == 2
# =========================================================================
# Tier B++ — Symbol mismatch protection
# =========================================================================
class TestSymbolMismatch:
"""Reject responses where returned symbol doesn't match the request."""
def test_quote_symbol_mismatch_falls_back(self):
"""Single-symbol quote returning wrong symbol is rejected."""
client = _make_client()
wrong = _mock_response(200, [{"symbol": "SPY", "price": 500.0}])
correct = _mock_response(200, [{"symbol": "^GSPC", "price": 5000.0}])
client.session.get = MagicMock(side_effect=[wrong, correct])
result = client.get_quote("^GSPC")
assert result == [{"symbol": "^GSPC", "price": 5000.0}]
assert client.session.get.call_count == 2
def test_historical_symbol_mismatch_falls_back(self):
"""Single-symbol historical returning wrong symbol is rejected."""
client = _make_client()
wrong = _mock_response(200, {"symbol": "SPY", "historical": [{"close": 500}]})
correct = _mock_response(200, {"symbol": "^GSPC", "historical": [{"close": 5000}]})
client.session.get = MagicMock(side_effect=[wrong, correct])
result = client.get_historical_prices("^GSPC", days=80)
assert result["symbol"] == "^GSPC"
assert client.session.get.call_count == 2
def test_batch_quote_skips_symbol_check(self):
"""Multi-symbol (batch) quote does not apply symbol mismatch check."""
client = _make_client()
batch_data = [{"symbol": "^GSPC", "price": 5000}, {"symbol": "^VIX", "price": 20}]
resp = _mock_response(200, batch_data)
client.session.get = MagicMock(return_value=resp)
result = client.get_quote("^GSPC,^VIX")
assert result == batch_data
assert client.session.get.call_count == 1
# =========================================================================
# Tier C — Caller regression
# =========================================================================
class TestCallerRegression:
"""Tier C: Verify ftd_detector.main() handles FMPClient failures correctly."""
# IMPORTANT: patch `ftd_detector.FMPClient` (the symbol AS USED by main()),
# not the module-level `FMPClient` imported at the top of this file.
# When pytest runs both ftd-detector and market-top-detector test files in
# the same session, conftest evicts and re-imports `fmp_client` during
# skill switches. This produces multiple class objects from the same source
# file. Patching the test-file-level `FMPClient` reference would miss the
# class that `ftd_detector.main()` actually uses. Patching via
# `ftd_detector.FMPClient` always hits the class bound inside ftd_detector
# at the time main() executes.
def test_ftd_detector_exits_on_historical_failure(self):
"""get_historical_prices -> None => main() calls sys.exit(1) (fatal)."""
with (
patch.dict(os.environ, {"FMP_API_KEY": "test_key"}), # pragma: allowlist secret
patch("sys.argv", ["ftd_detector.py"]),
):
# Import inside patch to pick up env var
import ftd_detector
with (
patch.object(ftd_detector.FMPClient, "get_historical_prices", return_value=None),
patch.object(
ftd_detector.FMPClient,
"get_quote",
return_value=[{"symbol": "^GSPC", "price": 5000.0}],
),
):
with pytest.raises(SystemExit) as exc_info:
ftd_detector.main()
assert exc_info.value.code == 1
def test_ftd_detector_continues_on_quote_failure(self):
"""get_quote -> None => main() continues with warning (non-fatal)."""
with (
patch.dict(os.environ, {"FMP_API_KEY": "test_key"}), # pragma: allowlist secret
patch("sys.argv", ["ftd_detector.py"]),
):
import ftd_detector
sp500_hist = {
"historical": [
{
"date": f"2026-03-{20 - i:02d}",
"open": 5000.0,
"high": 5010.0,
"low": 4990.0,
"close": 5000.0 - i * 10,
"volume": 3_000_000_000,
}
for i in range(80)
]
}
qqq_hist = {
"historical": [
{
"date": f"2026-03-{20 - i:02d}",
"open": 450.0,
"high": 455.0,
"low": 445.0,
"close": 450.0 - i,
"volume": 50_000_000,
}
for i in range(80)
]
}
def mock_hist(symbol, days=365):
if symbol == "^GSPC":
return sp500_hist
elif symbol == "QQQ":
return qqq_hist
return None
with (
patch.object(
ftd_detector.FMPClient, "get_historical_prices", side_effect=mock_hist
),
patch.object(ftd_detector.FMPClient, "get_quote", return_value=None),
patch.object(ftd_detector, "generate_json_report"),
patch.object(ftd_detector, "generate_markdown_report"),
):
# Should NOT raise SystemExit — quote failure is non-fatal
ftd_detector.main()
class TestEODFlatListSuccess:
"""Issue #64: stable EOD flat list -> public method success (regression)."""
@patch("fmp_client.requests.Session")
def test_get_historical_prices_normalizes_flat_list(self, mock_session_class):
"""Flat list response from new EOD endpoint -> dict contract preserved."""
mock_session = MagicMock()
mock_session.get.return_value = _mock_response(
200,
[
{
"symbol": "SPY",
"date": "2026-04-29",
"open": 500.0,
"high": 502.0,
"low": 499.0,
"close": 501.0,
"volume": 1_000_000,
},
{
"symbol": "SPY",
"date": "2026-04-28",
"open": 498.0,
"high": 501.0,
"low": 497.0,
"close": 500.0,
"volume": 1_100_000,
},
],
)
mock_session_class.return_value = mock_session
client = _make_client()
client.session = mock_session
client.max_retries = 0
result = client.get_historical_prices("SPY", days=2)
assert isinstance(result, dict), f"expected dict, got {type(result).__name__}"
assert result["symbol"] == "SPY"
assert len(result["historical"]) == 2
assert result["historical"][0]["close"] == 501.0
# URL regression: must hit /historical-price-eod/full with from/to (not timeseries)
first_call = mock_session.get.call_args_list[0]
url = first_call[0][0]
params = first_call[1]["params"]
assert "historical-price-eod/full" in url
assert "from" in params and "to" in params
assert "timeseries" not in params
Related skills
How it compares
Use ftd-detector for offensive bottom-confirmation signals; pair with market-top-detector when you also need defensive top detection.
FAQ
What indexes does ftd-detector track?
ftd-detector tracks dual indexes—S&P 500 and NASDAQ—through a state machine for rally attempts, Follow-Through Day qualification, and post-FTD health monitoring using William O'Neil bottom-confirmation methodology.
When should traders invoke ftd-detector?
Traders invoke ftd-detector when evaluating market bottom signals, follow-through days, or re-entry timing after corrections. The skill confirms whether increasing equity exposure aligns with O'Neil FTD rules on dual major indexes.