
Vcp Screener
- 945 installs
- 2.5k repo stars
- Updated July 26, 2026
- tradermonty/claude-trading-skills
vcp-screener is a Claude Code trading skill that automatically screens for stocks exhibiting the Volatility Contraction Pattern using live Financial Modeling Prep API data for developers who run systematic equity scans.
About
vcp-screener is a tradermonty/claude-trading-skills workflow that screens US equities for Volatility Contraction Pattern setups via the Financial Modeling Prep API. Phase 1 pulls S&P 500 constituents, batches quote requests in groups of five across roughly 101 calls for 503 stocks, and prefilters candidates; later phases fetch up to 260-day historical price series for SPY and finalists. Developers with an FMP API key use it to replace manual chart review with a repeatable VCP scan. Invoke when you need live pattern detection rather than static FinViz filter URLs.
- Screens the full S&P 500 universe for VCP setups
- Uses 3 FMP API endpoints: constituents, batch quotes, and 260-day historical prices
- Default run stays under free-tier API limits (~203 calls)
- Built-in rate limiting, retries, and in-memory caching
- Outputs ranked candidates with trend template and VCP detection
Vcp Screener by the numbers
- 945 all-time installs (skills.sh)
- +47 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #150 of 1,136 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill vcp-screenerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 945 |
|---|---|
| repo stars | ★ 2.5k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 26, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you screen stocks for Volatility Contraction Pattern?
Automatically screen for stocks exhibiting the Volatility Contraction Pattern using live FMP API data.
Who is it for?
Developers and systematic traders with an FMP API key who want automated VCP scans across the S&P 500 universe.
Skip if: Skip vcp-screener when you only need FinViz filter URLs or lack Financial Modeling Prep API access.
When should I use this skill?
User asks to screen for Volatility Contraction Pattern, VCP setups, or FMP-based contraction scans.
What you get
Ranked VCP candidate list with symbols, quotes, and 260-day historical price context from FMP API
- VCP candidate watchlist
- Batch quote snapshots
- 260-day historical price context
By the numbers
- ~101 batch quote API calls for 503 S&P 500 stocks at 5 symbols per request
- Fetches 260-day historical price series via timeseries=260 parameter
- Evaluates up to 100 candidate symbols after pre-filtering
Files
VCP Screener - Minervini Volatility Contraction Pattern
Screen S&P 500 stocks for Mark Minervini's Volatility Contraction Pattern (VCP), identifying Stage 2 uptrend stocks with contracting volatility near breakout pivot points.
When to Use
- User asks for VCP screening or Minervini-style setups
- User wants to find tight base / volatility contraction patterns
- User requests Stage 2 momentum stock scanning
- User asks for breakout candidates with defined risk
- User asks "find every historical VCP in <TICKER>" or wants to study one ticker's
past VCP setups with forward outcomes (--history --ticker SYM)
Prerequisites
- FMP API key (set
FMP_API_KEYenvironment variable or pass--api-key) - Free tier (250 calls/day) is sufficient for default screening (top 100 candidates)
- Paid tier recommended for full S&P 500 screening (
--full-sp500)
Workflow
Step 1: Prepare and Execute Screening
Run the VCP screener script:
# Default: S&P 500, top 100 candidates
python3 skills/vcp-screener/scripts/screen_vcp.py --output-dir skills/vcp-screener/scripts
# Custom universe
python3 skills/vcp-screener/scripts/screen_vcp.py --universe AAPL NVDA MSFT AMZN META --output-dir skills/vcp-screener/scripts
# Full S&P 500 (paid API tier)
python3 skills/vcp-screener/scripts/screen_vcp.py --full-sp500 --output-dir skills/vcp-screener/scriptsStrict Mode (Minervini pure setup)
Only return stocks with valid_vcp=True AND execution_state in (Pre-breakout, Breakout):
python3 skills/vcp-screener/scripts/screen_vcp.py --strict --output-dir reports/Historical single-ticker mode
Walk one ticker's multi-year history, detect every VCP that ever formed, and attach forward-outcome stats (breakout / stop-hit / timeout, days-to-outcome, max gain, max loss) per detection. Useful for pattern study and backtesting context — not a real-time screener.
# Default: scan ~5 years (1260 trading days), 5-day stride, 60-day outcome window
python3 skills/vcp-screener/scripts/screen_vcp.py \
--history --ticker FIX --output-dir reports/
# Custom scan length: 750 trading days (~3 years), 90-day outcome window
python3 skills/vcp-screener/scripts/screen_vcp.py \
--history 750 --ticker TSLA \
--stride-days 5 --outcome-days 90 \
--output-dir reports/
# Long scan: 10 years (2520 trading days)
python3 skills/vcp-screener/scripts/screen_vcp.py \
--history 2520 --ticker NVDA --output-dir reports/Outputs (timestamped):
vcp_history_<SYM>_<YYYY-MM-DD_HHMMSS>.json— timeline of detections with full
analyzer payload + forward_outcome per detection + summary stats.
vcp_history_<SYM>_<YYYY-MM-DD_HHMMSS>.md— human-readable timeline.
Mode-specific flags:
| Parameter | Default | Range | Effect |
|---|---|---|---|
--history [DAYS] | (off) / 1260 if bare | 100-5040 | Enable historical mode; optionally specify trading-day scan window (requires --ticker) |
--ticker SYM | — | — | Ticker to scan |
--stride-days | 5 | 1-60 | Trading-day step between as-of cursor positions |
--outcome-days | 60 | 5-252 | Forward window evaluated per detection |
Notes:
- Two FMP API calls per scan (ticker + SPY history), not 100+ like the
cross-sectional pipeline.
marketCapand absolute RS percentile reflect the ticker in isolation,
not against the live screening universe — use this report for pattern study, not portfolio sizing.
- Detections are deduplicated by
(T1_high_date, last_low_date, pivot)so
the same VCP isn't reported repeatedly as the cursor ages.
Advanced Tuning (for backtesting)
Adjust VCP detection parameters for research and backtesting:
python3 skills/vcp-screener/scripts/screen_vcp.py \
--min-contractions 3 \
--t1-depth-min 12.0 \
--breakout-volume-ratio 2.0 \
--trend-min-score 90 \
--atr-multiplier 1.5 \
--output-dir reports/| Parameter | Default | Range | Effect |
|---|---|---|---|
--min-contractions | 2 | 2-4 | Higher = fewer but higher-quality patterns |
--t1-depth-min | 10.0% | 1-50 | Higher = excludes shallow first corrections |
--breakout-volume-ratio | 1.5x | 0.5-10 | Higher = stricter volume confirmation |
--trend-min-score | 85 | 0-100 | Higher = stricter Stage 2 filter |
--atr-multiplier | 1.5 | 0.5-5 | Lower = more sensitive swing detection |
--contraction-ratio | 0.70 | 0.1-1 | Lower = requires tighter contractions |
--min-contraction-days | 5 | 1-30 | Higher = longer minimum contraction |
--lookback-days | 120 | 30-365 | Longer = finds older patterns |
--max-sma200-extension | 50.0% | — | SMA200 distance threshold for Overextended state and penalty |
--wide-and-loose-threshold | 15.0% | — | Final contraction depth above which wide-and-loose flag triggers |
--strict | off | — | Minervini strict mode: only Pre-breakout or Breakout with valid VCP |
Step 2: Review Results
1. Read the generated JSON and Markdown reports 2. Load references/vcp_methodology.md for pattern interpretation context 3. Load references/scoring_system.md for score threshold guidance
Step 3: Present Analysis
For each top candidate, present:
- Quality (
composite_score/ rating) — how well-formed is the VCP pattern? - Execution State (
execution_state) — is it buyable now? (Pre-breakout / Breakout = actionable) - Pattern Type (
pattern_type) — Textbook VCP / VCP-adjacent / Post-breakout / Extended Leader / Damaged ★marker if a State Cap was applied (raw score was downgraded)- Contraction details (T1/T2/T3 depths and ratios)
- Trade setup: pivot price, stop-loss, risk percentage
- Volume dry-up ratio and breakout_volume_score
- Relative strength rank
Step 4: Provide Actionable Guidance
By Execution State (primary filter):
- Pre-breakout / Breakout: Pattern is in the active entry window — apply rating-based sizing
- Early-post-breakout: Breakout underway but above ideal entry — reduced size or wait for pullback
- Extended / Overextended: Trade missed — add to watchlist for next base
- Damaged / Invalid: Setup invalidated — do not enter
By Rating (secondary, after state confirms actionability):
- Textbook VCP (90+): Buy at pivot with aggressive sizing (1.5-2x)
- Strong VCP (80-89): Buy at pivot with standard sizing (1x)
- Good VCP (70-79): Buy on volume confirmation above pivot (0.75x)
- Developing (60-69): Add to watchlist, wait for tighter contraction
- Weak/No VCP (<60): Monitor only or skip
3-Phase Pipeline
1. Pre-Filter - Quote-based screening (price, volume, 52w position) ~101 API calls 2. Trend Template - 7-point Stage 2 filter with 260-day histories ~100 API calls 3. VCP Detection - Pattern analysis, scoring, report generation (no additional API calls)
Output
vcp_screener_YYYY-MM-DD_HHMMSS.json- Structured resultsvcp_screener_YYYY-MM-DD_HHMMSS.md- Human-readable report
Resources
references/vcp_methodology.md- VCP theory and Trend Template explanationreferences/scoring_system.md- Scoring thresholds and component weightsreferences/fmp_api_endpoints.md- API endpoints and rate limits
FMP API Endpoints Used by VCP Screener
Endpoints
1. S&P 500 Constituents
- URL:
GET /stable/sp500-constituent - Calls: 1 (cached)
- Returns:
[{symbol, name, sector, subSector}, ...] - Used in: Phase 1 - Universe definition
2. Batch Quote
- URL:
GET /api/v3/quote/{symbols}(comma-separated, max 5) - Calls: ~101 (503 stocks / 5 per batch)
- Returns:
[{symbol, price, yearHigh, yearLow, avgVolume, marketCap, ...}] - Used in: Phase 1 - Pre-filter
3. Historical Prices
- URL:
GET /api/v3/historical-price-full/{symbol}?timeseries=260 - Calls: 1 (SPY) + up to 100 (candidates)
- Returns:
{symbol, historical: [{date, open, high, low, close, adjClose, volume}, ...]} - Used in: Phase 2 - Trend Template, Phase 3 - VCP detection
API Budget Summary
| Phase | Operation | API Calls |
|---|---|---|
| 1 | S&P 500 constituents | 1 |
| 1 | Batch quotes (503 / 5) | ~101 |
| 2 | SPY 260-day history | 1 |
| 2 | Candidate histories (max 100) | 100 |
| Total (default) | ~203 | |
| Total (--full-sp500) | ~350 |
Rate Limits
- Free tier: 250 API calls/day - Default screening fits within this limit
- Starter tier ($29.99/mo): 750 calls/day
- Rate limiting: 0.3s delay between requests, automatic retry on 429
- Caching: In-memory session cache prevents duplicate requests
Notes
- All historical data uses
timeseries=260parameter (260 trading days = ~1 year) - Phase 3 (VCP detection, scoring, reporting) requires NO additional API calls
- The
--full-sp500flag fetches histories for all pre-filter passers (~250 stocks)
VCP Screener Scoring System
5-Component Composite Score
| Component | Weight | Source |
|---|---|---|
| Trend Template (Stage 2) | 25% | 7-point Minervini criteria |
| Contraction Quality | 25% | VCP pattern detection |
| Volume Pattern | 20% | Volume dry-up analysis |
| Pivot Proximity | 15% | Distance from breakout level |
| Relative Strength | 15% | Minervini-weighted RS vs S&P 500 |
Component Scoring Details
1. Trend Template (0-100)
Each of the 7 criteria contributes 14.3 points:
| Criteria Passed | Score | Status |
|---|---|---|
| 7/7 | 100 | Perfect Stage 2 |
| 6/7 | 85.8 | Pass (minimum threshold) |
| 5/7 | 71.5 | Borderline |
| <= 4/7 | <= 57 | Fail |
Pass threshold: Raw score >= 85 (6+ criteria) to proceed to VCP analysis.
SMA200 Extension Penalty (metadata only -- NOT applied to the trend template score):
| Price above SMA200 | Penalty (stored as metadata) |
|---|---|
| > 70% | -20 |
| > 60% | -15 |
| > 50% | -10 |
| > 40% | -5 |
The SMA200 penalty is computed and stored in sma200_penalty / sma200_distance_pct for downstream use by the Execution State engine (Overextended classification). It is intentionally excluded from the trend template score to avoid double-penalizing with state caps.
2. Contraction Quality (0-100)
| # Contractions | Base Score |
|---|---|
| 4 | 90 |
| 3 | 80 |
| 2 | 60 |
| 1 or invalid | 0-40 |
Modifiers:
- Tight final contraction (< 5% depth): +10
- Good average contraction ratio (< 0.4 of T1): +10
- Deep T1 (> 30%): -10
3. Volume Pattern (0-100)
Based on dry-up ratio (Zone B avg volume / 50-day avg):
| Dry-Up Ratio | Base Score |
|---|---|
| < 0.30 | 90 |
| 0.30-0.50 | 75 |
| 0.50-0.70 | 60 |
| 0.70-1.00 | 40 |
| > 1.00 | 20 |
Zone-based analysis (when contractions are provided):
- Zone A: Last contraction period (volume during tightening)
- Zone B: Pivot approach — bars 1-10 (bar[0] excluded to avoid breakout contamination)
- Zone C: Bar[0] if price > pivot (breakout bar volume)
Zone B is the dry-up source. Bar[0] (potential breakout bar) is intentionally excluded from the dry-up calculation. Its quality is tracked separately via breakout_volume_score.
Breakout Volume Score (independent of dry-up):
| Zone C ratio (bar[0] / 50d avg) | Score |
|---|---|
| ≥ 3.0x | 100 |
| 2.0x–2.9x | 80 |
| 1.5x–1.9x | 60 |
| 1.0x–1.4x | 30 |
| < 1.0x or below pivot | 0 |
Modifiers to composite volume score:
- Breakout on 1.5x+ volume (bar[0] above pivot): +10
- Net accumulation > 3 days (in 20d): +10
- Net distribution > 3 days (in 20d): -10
- Declining volume across contraction periods: +10
4. Pivot Proximity (0-100) — Distance-Priority Scoring
Scoring is distance-first. Volume confirmation adds a bonus only within 0-5% above pivot (Minervini: never chase >5% above pivot).
| Distance from Pivot | Base Score | Volume Bonus | Final Score | Trade Status |
|---|---|---|---|---|
| 0-3% above | 90 | +10 | 100 | BREAKOUT CONFIRMED |
| 3-5% above | 65 | +10 | 75 | EXTENDED - Moderate chase risk (vol confirmed) |
| 5-10% above | 50 | — (none) | 50 | EXTENDED - High chase risk |
| 10-20% above | 35 | — (none) | 35 | EXTENDED - Very high chase risk |
| >20% above | 20 | — (none) | 20 | OVEREXTENDED - Do not chase |
| 0 to -2% below | 90 | — | 90 | AT PIVOT (within 2%) |
| -2% to -5% | 75 | — | 75 | NEAR PIVOT |
| -5% to -8% | 60 | — | 60 | APPROACHING |
| -8% to -10% | 45 | — | 45 | DEVELOPING |
| -10% to -15% | 30 | — | 30 | EARLY |
| < -15% | 10 | — | 10 | FAR FROM PIVOT |
Volume bonus rules:
- 0-3% above pivot + volume: +10 points, status = "BREAKOUT CONFIRMED"
- 3-5% above pivot + volume: +10 points, "(vol confirmed)" appended to status
- >5% above pivot: no volume bonus (Minervini: do not chase extended breakouts)
- Below pivot: volume bonus not applicable
Chase risk rule (Minervini): Do not buy stocks >5% above their pivot point. Distance determines the base score; volume confirmation is a bonus, not an override.
5. Relative Strength (0-100)
Minervini weighting (emphasizes recent performance):
- 40%: Last 3 months (63 trading days)
- 20%: Last 6 months (126 trading days)
- 20%: Last 9 months (189 trading days)
- 20%: Last 12 months (252 trading days)
| Weighted RS vs S&P 500 | Score | RS Rank Estimate |
|---|---|---|
| >= +50% | 100 | ~99 (top 1%) |
| >= +30% | 95 | ~95 (top 5%) |
| >= +20% | 90 | ~90 (top 10%) |
| >= +10% | 80 | ~80 (top 20%) |
| >= +5% | 70 | ~70 (top 30%) |
| >= 0% | 60 | ~60 (top 40%) |
| >= -5% | 50 | ~50 (average) |
| >= -10% | 40 | ~40 |
| >= -20% | 20 | ~25 |
| < -20% | 0 | ~10 |
2-Axis Scoring: Quality × Execution State
The screener separates two independent questions:
- Axis 1 — Structure Quality (
composite_score): How well-formed is the VCP pattern? (unchanged 5-component weighted score) - Axis 2 — Execution State (
execution_state): Is the stock buyable right now?
These axes are computed independently and then combined through State Caps.
---
Execution State Engine
compute_execution_state() applies a 10-rule decision tree and returns one of 7 states:
| State | Meaning |
|---|---|
Invalid | Price below SMA50 < SMA200 — not a Stage 2 stock |
Damaged | Price below last contraction low OR below SMA50 — pattern invalidated |
Overextended | Price >50% above SMA200 OR >10% above pivot — late-cycle risk |
Extended | Price 5-10% above pivot — elevated chase risk |
Early-post-breakout | Price 3-5% above pivot, OR 0-3% above pivot without volume confirmation |
Breakout | Price 0-3% above pivot with breakout volume confirmation (1.5x+ avg) |
Pre-breakout | Price below pivot — ideal entry zone |
State Caps
Each execution state imposes a maximum allowable rating regardless of composite score:
| Execution State | Maximum Rating | Rationale |
|---|---|---|
Invalid | No VCP | Price structure failed completely |
Damaged | No VCP | Pattern invalidated by breach of low |
Overextended | Weak VCP | Too extended for a safe entry |
Extended | Developing VCP | Chasing risk too high |
Early-post-breakout | Strong VCP | Breakout in progress; watch for follow-through |
Breakout | Textbook VCP | No cap — valid breakout |
Pre-breakout | Textbook VCP | No cap — ideal setup |
When a cap is applied, state_cap_applied=True and ★ appears in the Quick Scan table.
Wide-and-Loose Cap
When the final contraction has depth_pct > 15% AND duration_days < 10, the pattern is flagged as wide_and_loose=True and capped at Developing VCP (prevents Textbook/Strong/Good ratings for sloppy late contractions).
---
Pattern Classifier
classify_pattern() assigns one of 5 pattern types based on structural characteristics:
| Pattern Type | Criteria |
|---|---|
Textbook VCP | valid_vcp=True, not wide_and_loose, 3+ contractions, final depth ≤10%, dry_up ≤0.70, state=Pre-breakout |
VCP-adjacent | valid_vcp=True but misses one Textbook criterion |
Post-breakout | state in (Breakout, Early-post-breakout) |
Extended Leader | state in (Overextended, Extended) |
Damaged | state in (Invalid, Damaged) |
---
Rating Bands
| Composite Score | Rating | Position Sizing | Action |
|---|---|---|---|
| 90-100 | Textbook VCP | 1.5-2x normal | Buy at pivot, aggressive |
| 80-89 | Strong VCP | 1x normal | Buy at pivot, standard |
| 70-79 | Good VCP | 0.75x normal | Buy on volume confirmation |
| 60-69 | Developing VCP | Wait | Watchlist only |
| 50-59 | Weak VCP | Skip | Monitor only |
| < 50 | No VCP | Skip | Not actionable |
Cap Priority
Multiple caps can apply simultaneously. The most restrictive (lowest) cap wins:
1. valid_vcp=False cap (Developing VCP max) 2. Execution State cap (per table above) 3. Wide-and-Loose cap (Developing VCP max)
The final displayed rating reflects all caps. state_cap_applied=True indicates at least one cap was applied.
Entry Ready Conditions
A stock is classified as entry_ready=True when all of the following conditions are met:
| Condition | Default Threshold | CLI Override |
|---|---|---|
execution_state | Not in (Invalid, Damaged, Overextended, Extended, Early-post-breakout) | — |
valid_vcp | True | --no-require-valid-vcp |
distance_from_pivot_pct | -8.0% to +3.0% | --max-above-pivot |
dry_up_ratio | <= 1.0 | — |
trade_status | Not "BELOW STOP LEVEL" | — |
risk_pct | > 0% and <= 15.0% | --max-risk |
Report sections:
- Section A: Pre-Breakout Watchlist —
entry_ready=Truestocks, sorted by composite score - Section B: Extended / Quality VCP —
entry_ready=Falsestocks, sorted by composite score
CLI mode:
--mode all(default): Shows both sections--mode prebreakout: Shows only entry_ready=True stocks--strict: Minervini strict mode — only includes stocks withvalid_vcp=TrueANDexecution_state in (Pre-breakout, Breakout)
Pre-Filter Criteria (Phase 1)
Quick filter using quote data only (no historical needed):
| Criterion | Threshold | Purpose |
|---|---|---|
| Price | > $10 | Exclude penny stocks |
| % above 52w low | > 20% | Roughly in uptrend |
| % below 52w high | < 30% | Not in deep correction |
| Average volume | > 200,000 | Sufficient liquidity |
VCP Methodology - Minervini's Volatility Contraction Pattern
Overview
The Volatility Contraction Pattern (VCP) was developed by Mark Minervini, two-time U.S. Investing Championship winner. It identifies stocks in Stage 2 uptrends that are forming progressively tighter consolidation patterns before a potential breakout.
Stage Analysis Foundation
The 4 Stages (Stan Weinstein / Minervini)
1. Stage 1 - Accumulation/Basing: Stock trades sideways after a decline. Smart money accumulates. 2. Stage 2 - Advancing/Uptrend: Stock is in a confirmed uptrend. This is where VCPs form. The only stage to buy. 3. Stage 3 - Distribution/Topping: Stock stalls after an advance. Smart money distributes. 4. Stage 4 - Declining/Downtrend: Stock is in a confirmed downtrend. Avoid or short.
Minervini's 7-Point Trend Template (Stage 2 Confirmation)
A stock MUST pass all (or nearly all) of these criteria to be in a confirmed Stage 2:
| # | Criterion | Purpose |
|---|---|---|
| 1 | Price > 150-day SMA AND Price > 200-day SMA | Above major trend lines |
| 2 | 150-day SMA > 200-day SMA | Shorter MA above longer (bullish alignment) |
| 3 | 200-day SMA trending up for 22+ trading days | Long-term trend is up |
| 4 | Price > 50-day SMA | Above intermediate trend line |
| 5 | Price at least 25% above 52-week low | Sufficient distance from lows |
| 6 | Price within 25% of 52-week high | Not in a deep correction |
| 7 | Relative Strength rating > 70 | Outperforming most stocks |
Pass threshold: 6 of 7 criteria (score >= 85) to proceed to VCP detection.
VCP Pattern Mechanics
What is a VCP?
A VCP occurs when a stock in a Stage 2 uptrend pulls back and then rallies, but each successive pullback is shallower (less volatile) than the previous one. This "volatility contraction" signals that:
1. Selling pressure is being absorbed 2. Remaining sellers are being exhausted 3. Supply is drying up 4. A breakout becomes more probable
Contraction Structure
H1 (Highest swing high)
/ \
/ \ T1 (First contraction: deepest)
/ \
/ L1
/ / \
/ / \ T2 (Second contraction: tighter)
H2 \
\ L2
\ / \
\/ \ T3 (Third contraction: tightest)
H3 L3
\ /
\ / ← PIVOT POINT (buy here on volume)
PContraction Rules
- T1 (first correction): 8-35% depth for S&P 500 large-caps (up to 50% for small-caps)
- T2: Must be at least 25% tighter than T1 (ratio <= 0.75)
- T3: Must be at least 25% tighter than T2 (if present)
- T4: Extremely tight, often < 5% (rare, very bullish)
- Minimum: 2 contractions required
- Ideal: 3-4 contractions with progressive tightening
- Duration: 15-325 trading days for the complete pattern
Pivot Point
The pivot is the high of the last contraction. This is the buy point:
- Buy when price moves above the pivot on volume 1.5x+ above the 50-day average
- Place stop-loss 1-2% below the last contraction low
- Risk per trade should be 5-8% from entry to stop
Volume Signature
Ideal volume behavior during a VCP:
1. During corrections: Volume should decrease (sellers exhausting) 2. Near the pivot: Volume should "dry up" (extremely low, calm before the storm) 3. On breakout: Volume should surge to 1.5-2x the 50-day average
Dry-up ratio = Average volume (last 10 bars near pivot) / 50-day average volume
- < 0.30: Exceptional (textbook)
- 0.30-0.50: Strong
- 0.50-0.70: Adequate
- \> 0.70: Weak (caution)
Historical VCP Examples
Classic 3-Contraction VCP
- T1: 20% pullback over 6 weeks
- T2: 12% pullback over 3 weeks (40% tighter)
- T3: 5% pullback over 2 weeks (58% tighter)
- Breakout on 2x volume → 50%+ advance
Tight 2-Contraction VCP (Large-Cap)
- T1: 12% pullback over 4 weeks
- T2: 5% pullback over 2 weeks (58% tighter)
- Breakout on 1.8x volume → 25-30% advance
Common Pitfalls
1. Buying before the pivot: Wait for the breakout, not the setup 2. Ignoring volume: A breakout without volume often fails 3. Wide stops: Keep stops tight (below last contraction low) 4. Wrong stage: VCPs only work in Stage 2; verify with Trend Template first 5. Deep T1: If T1 > 35% for large-caps, the pattern is less reliable 6. Expanding contractions: If T2 > T1, it's NOT a VCP
Position Sizing by VCP Quality
| Rating | Position Size | Risk Budget |
|---|---|---|
| Textbook (90+) | 1.5-2x normal | Full |
| Strong (80-89) | 1x normal | Full |
| Good (70-79) | 0.75x normal | Standard |
| Developing (60-69) | Wait/Watch | Reduced |
"""FMP ``/api/v3`` → ``/stable`` URL compatibility shim.
FMP retired the legacy ``/api/v3/`` surface on 2025-08-31; API keys issued
after that date receive ``403 "Legacy Endpoint"`` on every ``/api/v3/`` request.
This helper rewrites a legacy v3-style URL (and its params) to the ``/stable``
equivalent. It is applied ONLY at construction points that build hardcoded v3
URLs and are *not* part of an explicit stable→v3 fallback list. Methods that
already iterate a ``_FMP_ENDPOINTS`` stable→v3 table must NOT route through this
shim, or the v3 fallback entry would be rewritten back to stable and the
fallback contract would break.
Note on endpoint naming: ``/stable`` endpoint names are inconsistent. Most
legacy underscore names resolve, so unmapped endpoints fall through to a 1:1
underscore-preserving swap. But a few endpoints (``sp500_constituent`` and
``earning_calendar``) return **404 on the underscore form for all tiers** —
their live ``/stable`` name is hyphenated (verified 2026-06). Those are pinned
to the hyphenated form in ``_PATH_RENAME_NO_SYMBOL`` below. Do not "modernize"
the underscore-preserving fallthrough wholesale, and do not revert the pinned
endpoints back to underscore.
"""
from __future__ import annotations
from datetime import date, timedelta
_STABLE = "https://financialmodelingprep.com/stable"
# v3 path segment (symbol carried in the path) → /stable path (symbol via ?symbol=)
_PATH_WITH_SYMBOL = {
"quote": "/quote",
"profile": "/profile",
"income-statement": "/income-statement",
"balance-sheet-statement": "/balance-sheet-statement",
"cash-flow-statement": "/cash-flow-statement",
"key-metrics": "/key-metrics",
"ratios": "/ratios",
"enterprise-values": "/enterprise-values",
"market-capitalization": "/market-capitalization",
"institutional-holder": "/institutional-ownership/symbol-ownership",
"etf-holder": "/etf-holdings",
"rating": "/rating",
"discounted-cash-flow": "/discounted-cash-flow",
}
# v3 path → /stable path for endpoints that carry NO path symbol and whose
# /stable name differs from the v3 name. Explicit because the underscore
# (v3-style) /stable name 404s for these; the hyphenated name is the live one
# (verified 2026-06: /stable/sp500_constituent and /stable/earning_calendar
# both 404; the hyphenated variants are the live endpoints — 200 with a Premium
# key, lower tiers may 402). These override the underscore-preserving fallthrough.
_PATH_RENAME_NO_SYMBOL = {
"sp500_constituent": "/sp500-constituent",
"earning_calendar": "/earnings-calendar",
}
def v3_to_stable(url: str, params: dict | None = None) -> tuple[str, dict]:
"""Rewrite a legacy FMP v3 URL to its ``/stable`` equivalent.
No-op for URLs that do not contain ``/api/v3/``. Unmapped endpoints fall
back to a 1:1 underscore-preserving path swap; endpoints whose underscore
``/stable`` form 404s are pinned to hyphen via ``_PATH_RENAME_NO_SYMBOL``.
"""
params = {} if params is None else dict(params)
if "/api/v3/" not in url:
return url, params
after = url.split("/api/v3/", 1)[1].rstrip("/")
# historical-price-full has a dividend sub-path and a price variant
if after.startswith("historical-price-full/stock_dividend/"):
params["symbol"] = after[len("historical-price-full/stock_dividend/") :]
return _STABLE + "/dividends", params
if after.startswith("historical-price-full/"):
params["symbol"] = after[len("historical-price-full/") :]
# The stable EOD endpoint ignores ``timeseries``; convert to a from/to
# range (2x calendar days covers N trading days with weekend headroom).
timeseries = params.pop("timeseries", None)
if timeseries:
today = date.today()
params.setdefault("from", (today - timedelta(days=int(timeseries) * 2)).isoformat())
params.setdefault("to", today.isoformat())
return _STABLE + "/historical-price-eod/full", params
# historical/earning_calendar/{symbol} → earnings?symbol=
if after.startswith("historical/earning_calendar/"):
params["symbol"] = after[len("historical/earning_calendar/") :]
return _STABLE + "/earnings", params
# symbol-in-path endpoints → ?symbol=
for v3_path, stable_path in _PATH_WITH_SYMBOL.items():
if after.startswith(v3_path + "/"):
params["symbol"] = after[len(v3_path) + 1 :]
return _STABLE + stable_path, params
if after == v3_path:
return _STABLE + stable_path, params
# Explicit hyphenated renames for symbol-less endpoints whose underscore
# /stable form 404s (must come before the underscore-preserving fallthrough).
if after in _PATH_RENAME_NO_SYMBOL:
return _STABLE + _PATH_RENAME_NO_SYMBOL[after], params
# Best-effort 1:1 swap, preserving the underscore (v3-style) name. Endpoints
# whose underscore /stable form is known to 404 are pinned to hyphen above.
return _STABLE + "/" + after, params
# VCP Screener Calculators
#!/usr/bin/env python3
"""
Execution State Engine - Determines whether a VCP candidate is actionable now.
Separates "strong pattern" from "buy-able now":
- Structure Quality (composite_score): How good is the VCP pattern?
- Execution State: Can I actually enter at this price?
States (highest to lowest precedence):
Invalid - Price below SMA50 and SMA200 (not in Stage 2)
Damaged - Price below stop level or SMA50 violated
Overextended - Too far from pivot or SMA200 (chase risk)
Extended - 5-10% above pivot (elevated risk)
Early-post-breakout - 3-5% above pivot
Breakout - Within 3% of pivot with volume confirmation
Pre-breakout - Within or below pivot (ideal entry zone)
"""
from typing import Optional
def compute_execution_state(
distance_from_pivot_pct: Optional[float],
price: float,
sma50: Optional[float],
sma200: Optional[float],
sma200_distance_pct: Optional[float],
last_contraction_low: Optional[float],
breakout_volume: bool,
max_sma200_extension: float = 50.0,
) -> dict:
"""
Determine execution state from pre-calculated data.
Decision tree evaluated top-to-bottom (first match wins):
1. price < sma50 < sma200 → Invalid
2. price < last_contraction_low → Damaged
3. price < sma50 → Damaged
4. sma200_distance > max_sma200_ext → Overextended
5. pivot_distance > 10% → Overextended
6. pivot_distance 5-10% → Extended
7. pivot_distance 3-5% → Early-post-breakout
8. pivot_distance 0-3% + volume → Breakout
9. pivot_distance 0-3% → Early-post-breakout (volume unconfirmed)
10. pivot_distance < 0% → Pre-breakout
Args:
distance_from_pivot_pct: % distance from pivot (positive = above, negative = below)
price: Current stock price
sma50: 50-day simple moving average
sma200: 200-day simple moving average
sma200_distance_pct: % above SMA200 (positive = above)
last_contraction_low: Low of the last VCP contraction (stop level)
breakout_volume: True if today's volume confirms a breakout (1.5x+ avg)
max_sma200_extension: Max % above SMA200 before Overextended (default 50.0)
Returns:
dict with "state" (str) and "reasons" (list[str])
"""
reasons = []
# Rule 1: Invalid - not in Stage 2 (price below both moving averages)
if sma50 is not None and sma200 is not None:
if price < sma50 and sma50 < sma200:
reasons.append(f"Price ${price:.2f} < SMA50 ${sma50:.2f} < SMA200 ${sma200:.2f}")
return {"state": "Invalid", "reasons": reasons}
# Rule 2: Damaged - stop level violated
if last_contraction_low is not None and last_contraction_low > 0:
if price < last_contraction_low:
reasons.append(
f"Price ${price:.2f} below last contraction low ${last_contraction_low:.2f}"
)
return {"state": "Damaged", "reasons": reasons}
# Rule 3: Damaged - price below SMA50 (Stage 2 violation)
if sma50 is not None and price < sma50:
reasons.append(f"Price ${price:.2f} below SMA50 ${sma50:.2f}")
return {"state": "Damaged", "reasons": reasons}
# Rule 4: Overextended - too far above SMA200
if sma200_distance_pct is not None and sma200_distance_pct > max_sma200_extension:
reasons.append(
f"SMA200 distance {sma200_distance_pct:.1f}% > max {max_sma200_extension:.0f}%"
)
return {"state": "Overextended", "reasons": reasons}
# Rules 5-10 require pivot distance
if distance_from_pivot_pct is None:
reasons.append("No pivot available")
return {"state": "Pre-breakout", "reasons": reasons}
# Rule 5: Overextended - more than 10% above pivot
if distance_from_pivot_pct > 10.0:
reasons.append(f"+{distance_from_pivot_pct:.1f}% above pivot (> 10%)")
return {"state": "Overextended", "reasons": reasons}
# Rule 6: Extended - 5-10% above pivot
if distance_from_pivot_pct > 5.0:
reasons.append(f"+{distance_from_pivot_pct:.1f}% above pivot (5-10% zone)")
return {"state": "Extended", "reasons": reasons}
# Rule 7: Early-post-breakout - 3-5% above pivot
if distance_from_pivot_pct > 3.0:
reasons.append(f"+{distance_from_pivot_pct:.1f}% above pivot (3-5% zone)")
return {"state": "Early-post-breakout", "reasons": reasons}
# Rules 8-9: Within 3% of pivot (or below)
if distance_from_pivot_pct >= 0.0:
if breakout_volume:
reasons.append(f"+{distance_from_pivot_pct:.1f}% above pivot with volume confirmation")
return {"state": "Breakout", "reasons": reasons}
else:
reasons.append(f"+{distance_from_pivot_pct:.1f}% above pivot (volume unconfirmed)")
return {"state": "Early-post-breakout", "reasons": reasons}
# Rule 10: Below pivot
reasons.append(f"{distance_from_pivot_pct:.1f}% below pivot (forming pattern)")
return {"state": "Pre-breakout", "reasons": reasons}
# State ordering for cap enforcement (lower index = more restrictive)
STATE_ORDER = [
"Invalid",
"Damaged",
"Overextended",
"Extended",
"Early-post-breakout",
"Breakout",
"Pre-breakout",
]
# Maximum rating allowed per execution state
STATE_MAX_RATING = {
"Invalid": "No VCP",
"Damaged": "No VCP",
"Overextended": "Weak VCP",
"Extended": "Developing VCP",
"Early-post-breakout": "Strong VCP", # Cap: breakout in progress, not yet confirmed
"Breakout": None, # No cap
"Pre-breakout": None, # No cap
}
# Rating hierarchy (higher index = higher rating)
RATING_ORDER = [
"No VCP",
"Weak VCP",
"Developing VCP",
"Good VCP",
"Strong VCP",
"Textbook VCP",
]
def apply_state_cap(rating: str, execution_state: str) -> tuple[str, bool]:
"""
Apply the State Cap: if execution state restricts the maximum allowed rating,
downgrade the rating accordingly.
Args:
rating: Current rating string
execution_state: Execution state string
Returns:
(capped_rating: str, cap_applied: bool)
"""
max_rating = STATE_MAX_RATING.get(execution_state)
if max_rating is None:
return rating, False # No cap for this state
current_idx = RATING_ORDER.index(rating) if rating in RATING_ORDER else 0
max_idx = RATING_ORDER.index(max_rating) if max_rating in RATING_ORDER else 0
if current_idx > max_idx:
return max_rating, True
return rating, False
#!/usr/bin/env python3
"""Forward Outcome Calculator — what happened after the VCP was detected.
Used by the historical single-ticker scanner to label each detected VCP with
the trade-outcome it would have produced if entered at the pivot:
- ``breakout`` : close > pivot within max_window_days
- ``stop_hit`` : close < stop within window, before any breakout
- ``timeout`` : neither hit; window expired
- ``insufficient_data``: fewer than 1 forward bar available
Conventions:
- ``historical`` is most-recent-first (index 0 = most recent bar).
- ``as_of_offset`` is the MRF index treated as the detection day; forward bars
are ``historical[0 : as_of_offset]`` walked oldest-to-newest.
- ``max_gain_pct`` and ``max_loss_pct`` are measured against the as-of close,
not the pivot; they describe the trajectory regardless of outcome.
"""
from typing import Optional
def calculate_forward_outcome(
historical_prices: list[dict],
as_of_offset: int,
pivot_price: float,
stop_price: Optional[float] = None,
max_window_days: int = 60,
) -> dict:
"""Compute the trade outcome for a VCP detected at ``historical_prices[as_of_offset]``.
Args:
historical_prices: Most-recent-first OHLCV bars.
as_of_offset: Index of the detection bar. Forward window is the bars
with smaller indices (i.e., more recent than the as-of bar).
pivot_price: Breakout trigger. First forward bar whose close exceeds
this is the breakout day.
stop_price: Stop-loss level (typically the last contraction's low). If
None, stop_hit is never reported. First forward bar whose close
falls below this — before any breakout — is the stop-hit day.
max_window_days: Number of forward bars to evaluate (default 60).
Returns:
Dict with the schema documented at the top of this module.
"""
empty = {
"outcome_type": "insufficient_data",
"days_to_outcome": None,
"exit_price": None,
"exit_date": None,
"max_gain_pct": None,
"max_loss_pct": None,
"pivot_price": pivot_price,
"stop_price": stop_price,
"bars_available": 0,
"bars_evaluated": 0,
}
if not historical_prices or as_of_offset <= 0 or as_of_offset >= len(historical_prices):
return empty
as_of_close = historical_prices[as_of_offset].get("close")
if as_of_close in (None, 0):
return empty
# Forward bars in oldest-to-newest order (start at the bar right after
# the as-of bar and walk toward the present).
forward = list(reversed(historical_prices[:as_of_offset]))
bars_available = len(forward)
if bars_available == 0:
return empty
window = forward[:max_window_days]
outcome_type = "timeout"
days_to_outcome: Optional[int] = None
exit_price: Optional[float] = None
exit_date: Optional[str] = None
max_gain_pct: Optional[float] = None
max_loss_pct: Optional[float] = None
# Walk the entire forward window so max_gain / max_loss describe the full
# trajectory regardless of where the outcome is triggered. The outcome
# itself is set only at the first triggering bar.
for i, bar in enumerate(window, start=1):
close = bar.get("close")
if close is None:
continue
gain_pct = (close - as_of_close) / as_of_close * 100
if max_gain_pct is None or gain_pct > max_gain_pct:
max_gain_pct = gain_pct
if max_loss_pct is None or gain_pct < max_loss_pct:
max_loss_pct = gain_pct
if outcome_type == "timeout":
if close > pivot_price:
outcome_type = "breakout"
days_to_outcome = i
exit_price = close
exit_date = bar.get("date")
elif stop_price is not None and close < stop_price:
outcome_type = "stop_hit"
days_to_outcome = i
exit_price = close
exit_date = bar.get("date")
return {
"outcome_type": outcome_type,
"days_to_outcome": days_to_outcome,
"exit_price": round(exit_price, 4) if exit_price is not None else None,
"exit_date": exit_date,
"max_gain_pct": round(max_gain_pct, 4) if max_gain_pct is not None else None,
"max_loss_pct": round(max_loss_pct, 4) if max_loss_pct is not None else None,
"pivot_price": pivot_price,
"stop_price": stop_price,
"bars_available": bars_available,
"bars_evaluated": len(window),
}
#!/usr/bin/env python3
"""
Pattern Classifier - Categorizes VCP candidates by pattern type.
Separates structural quality (what kind of pattern is this?) from
execution state (can I enter now?). The two axes together give a
complete picture of each candidate.
Pattern Types (priority order, first match wins):
Damaged - Invalid/Damaged execution state; setup not viable
Extended Leader - Valid VCP structure but already extended (>5% above pivot)
Post-breakout - Breaking out or just broke out (0-5% above pivot + volume)
Textbook VCP - Ideal pre-breakout setup (3+ contractions, tight, dry volume)
VCP-adjacent - Some VCP characteristics but not fully textbook-quality
"""
from typing import Optional
def classify_pattern(
valid_vcp: bool,
num_contractions: int,
final_contraction_depth: Optional[float],
execution_state: str,
dry_up_ratio: Optional[float],
wide_and_loose: bool = False,
) -> str:
"""
Classify the pattern type for a VCP candidate.
Decision tree (evaluated top-to-bottom, first match wins):
1. execution_state in (Invalid, Damaged) → Damaged
2. valid_vcp + execution_state in (Overextended,
Extended, Early-post-breakout, Breakout) → Extended Leader / Post-breakout
3. not valid_vcp + extended states → VCP-adjacent
4. Pre-breakout + valid + textbook criteria → Textbook VCP
5. Pre-breakout + valid (not textbook) → VCP-adjacent
6. All other valid patterns → VCP-adjacent
Textbook VCP criteria (ALL must be met):
- valid_vcp is True
- not wide_and_loose
- num_contractions >= 3
- final_contraction_depth <= 10.0%
- dry_up_ratio <= 0.7 (significant volume dry-up)
- execution_state == "Pre-breakout"
Args:
valid_vcp: Whether VCP contraction ratios passed validation
num_contractions: Number of detected contractions
final_contraction_depth: Depth (%) of the last contraction
execution_state: Output of compute_execution_state() — e.g. "Pre-breakout"
dry_up_ratio: Volume dry-up ratio (lower = more dry-up)
wide_and_loose: True if final contraction is wide/deep (Phase 3 flag)
Returns:
Pattern type string: "Textbook VCP" | "VCP-adjacent" |
"Post-breakout" | "Extended Leader" | "Damaged"
"""
# Rule 1: Damaged execution states — setup not viable
if execution_state in ("Invalid", "Damaged"):
return "Damaged"
# Rules 2-3: Extended / post-breakout states
if execution_state in ("Overextended", "Extended"):
return "Extended Leader" if valid_vcp else "VCP-adjacent"
if execution_state in ("Early-post-breakout", "Breakout"):
return "Post-breakout" if valid_vcp else "VCP-adjacent"
# Rules 4-6: Pre-breakout (or no-pivot) states
if not valid_vcp or wide_and_loose:
return "VCP-adjacent"
# Check Textbook VCP criteria
textbook = (
num_contractions >= 3
and (final_contraction_depth is None or final_contraction_depth <= 10.0)
and (dry_up_ratio is not None and dry_up_ratio <= 0.7)
and execution_state == "Pre-breakout"
)
return "Textbook VCP" if textbook else "VCP-adjacent"
#!/usr/bin/env python3
"""
Pivot Proximity Calculator - Breakout Distance & Risk Analysis
Calculates how close the current price is to the VCP pivot (breakout) point
and computes the risk profile for a potential trade.
Distance-priority scoring (Minervini: do not chase >5% above pivot):
- 0-3% above pivot: 90 (+ volume bonus 10 = 100 BREAKOUT CONFIRMED)
- 3-5% above: 65 (+ volume bonus 10 = 75)
- 5-10% above: 50 (no volume bonus)
- 10-20% above: 35 (no volume bonus)
- >20% above: 20 (no volume bonus)
- 0 to -2% below: 90 (AT PIVOT)
- -2% to -5%: 75 (NEAR PIVOT)
- -5% to -8%: 60 (APPROACHING)
- -8% to -10%: 45 (DEVELOPING)
- -10% to -15%: 30 (EARLY)
- < -15%: 10 (FAR FROM PIVOT)
Also calculates:
- Stop-loss price (below last contraction low)
- Risk % per share (entry to stop distance)
"""
from typing import Optional
def calculate_pivot_proximity(
current_price: float,
pivot_price: Optional[float],
last_contraction_low: Optional[float] = None,
breakout_volume: bool = False,
) -> dict:
"""
Calculate proximity to pivot point and risk metrics.
Args:
current_price: Current stock price
pivot_price: The pivot (breakout) price from VCP pattern
last_contraction_low: Low of the last contraction (for stop-loss)
breakout_volume: Whether current volume is 1.5x+ above average
Returns:
Dict with score (0-100), distance_pct, stop_loss, risk_pct
"""
if not pivot_price or pivot_price <= 0:
return {
"score": 0,
"distance_from_pivot_pct": None,
"stop_loss_price": None,
"risk_pct": None,
"trade_status": "NO PIVOT",
"error": "No valid pivot price",
}
if current_price <= 0:
return {
"score": 0,
"distance_from_pivot_pct": None,
"stop_loss_price": None,
"risk_pct": None,
"trade_status": "INVALID PRICE",
"error": "Invalid current price",
}
# Distance from pivot (negative = below pivot)
distance_pct = (current_price - pivot_price) / pivot_price * 100
# Determine trade status and score (distance-priority)
if distance_pct > 20:
score = 20
trade_status = "OVEREXTENDED - Do not chase"
elif distance_pct > 10:
score = 35
trade_status = "EXTENDED - Very high chase risk"
elif distance_pct > 5:
score = 50
trade_status = "EXTENDED - High chase risk"
elif distance_pct > 3:
score = 65
trade_status = "EXTENDED - Moderate chase risk"
elif distance_pct > 0:
score = 90
trade_status = "ABOVE PIVOT (within 3%)"
elif distance_pct >= -2:
score = 90
trade_status = "AT PIVOT (within 2%)"
elif distance_pct >= -5:
score = 75
trade_status = "NEAR PIVOT (2-5% below)"
elif distance_pct >= -8:
score = 60
trade_status = "APPROACHING (5-8% below)"
elif distance_pct >= -10:
score = 45
trade_status = "DEVELOPING (8-10% below)"
elif distance_pct >= -15:
score = 30
trade_status = "EARLY (10-15% below)"
else:
score = 10
trade_status = "FAR FROM PIVOT (>15% below)"
# Volume confirmation bonus (only for 0-5% above pivot)
if breakout_volume and distance_pct > 0:
if distance_pct <= 3:
score += 10
trade_status = "BREAKOUT CONFIRMED"
elif distance_pct <= 5:
score += 10
trade_status += " (vol confirmed)"
# Calculate stop-loss and risk
stop_loss_price = None
risk_pct = None
if last_contraction_low and last_contraction_low > 0:
# Stop-loss is 1-2% below the last contraction low
stop_loss_price = round(last_contraction_low * 0.99, 2)
# Risk per share from current price to stop
if current_price > stop_loss_price:
risk_pct = round((current_price - stop_loss_price) / current_price * 100, 2)
else:
# Price already below stop level — setup is invalidated
risk_pct = None
trade_status = "BELOW STOP LEVEL"
score = 0
return {
"score": score,
"distance_from_pivot_pct": round(distance_pct, 2),
"pivot_price": round(pivot_price, 2),
"stop_loss_price": stop_loss_price,
"risk_pct": risk_pct,
"trade_status": trade_status,
"error": None,
}
#!/usr/bin/env python3
"""
Relative Strength Calculator - Minervini Weighted RS
Calculates relative price performance vs S&P 500 using Minervini's weighting:
- 40% weight: Last 3 months (63 trading days)
- 20% weight: Last 6 months (126 trading days)
- 20% weight: Last 9 months (189 trading days)
- 20% weight: Last 12 months (252 trading days)
This emphasizes recent momentum more than a simple 52-week calculation.
Scoring:
- 100: Weighted RS outperformance >= +50% (top 1%)
- 95: >= +30% (top 5%)
- 90: >= +20% (top 10%)
- 80: >= +10% (top 20%)
- 70: >= +5% (top 30%)
- 60: >= 0% (top 40%)
- 50: >= -5% (average)
- 40: >= -10% (below average)
- 20: >= -20% (weak)
- 0: < -20% (laggard)
"""
# Minervini weighting periods (trading days) and weights
RS_PERIODS = [
(63, 0.40), # 3 months - 40%
(126, 0.20), # 6 months - 20%
(189, 0.20), # 9 months - 20%
(252, 0.20), # 12 months - 20%
]
def calculate_relative_strength(
stock_prices: list[dict],
sp500_prices: list[dict],
) -> dict:
"""
Calculate Minervini-weighted relative strength vs S&P 500.
Args:
stock_prices: Daily OHLCV for stock (most recent first), need 252+ days
sp500_prices: Daily OHLCV for SPY (most recent first), need 252+ days
Returns:
Dict with score (0-100), rs_rank_estimate, weighted_rs, period details
"""
if not stock_prices or len(stock_prices) < 63:
return {
"score": 0,
"rs_rank_estimate": 0,
"weighted_rs": None,
"error": "Insufficient stock price data (need 63+ days)",
}
if not sp500_prices or len(sp500_prices) < 63:
return {
"score": 0,
"rs_rank_estimate": 0,
"weighted_rs": None,
"error": "Insufficient S&P 500 price data (need 63+ days)",
}
stock_closes = [d.get("close", d.get("adjClose", 0)) for d in stock_prices]
sp500_closes = [d.get("close", d.get("adjClose", 0)) for d in sp500_prices]
weighted_rs = 0.0
total_weight = 0.0
period_details = []
for period_days, weight in RS_PERIODS:
if len(stock_closes) > period_days and len(sp500_closes) > period_days:
stock_return = _period_return(stock_closes, period_days)
sp500_return = _period_return(sp500_closes, period_days)
relative = stock_return - sp500_return
weighted_rs += relative * weight
total_weight += weight
period_details.append(
{
"period_days": period_days,
"weight": weight,
"stock_return_pct": round(stock_return, 2),
"sp500_return_pct": round(sp500_return, 2),
"relative_pct": round(relative, 2),
}
)
elif len(stock_closes) > period_days // 2 and len(sp500_closes) > period_days // 2:
# Partial data: use available days with reduced weight
available = min(len(stock_closes) - 1, len(sp500_closes) - 1)
stock_return = _period_return(stock_closes, available)
sp500_return = _period_return(sp500_closes, available)
relative = stock_return - sp500_return
reduced_weight = weight * 0.5
weighted_rs += relative * reduced_weight
total_weight += reduced_weight
period_details.append(
{
"period_days": period_days,
"weight": reduced_weight,
"stock_return_pct": round(stock_return, 2),
"sp500_return_pct": round(sp500_return, 2),
"relative_pct": round(relative, 2),
"note": f"Partial data ({available} days available)",
}
)
if total_weight > 0:
weighted_rs = weighted_rs / total_weight
else:
return {
"score": 0,
"rs_rank_estimate": 0,
"weighted_rs": None,
"error": "Unable to calculate weighted RS (insufficient overlapping data)",
}
# Score based on weighted relative performance
score, rs_rank = _score_rs(weighted_rs)
return {
"score": score,
"rs_rank_estimate": rs_rank,
"weighted_rs": round(weighted_rs, 2),
"period_details": period_details,
"error": None,
}
def _period_return(closes: list[float], period: int) -> float:
"""Calculate return over period. Closes are most-recent-first."""
if len(closes) <= period or closes[period] <= 0:
return 0.0
return ((closes[0] - closes[period]) / closes[period]) * 100
def rank_relative_strength_universe(rs_results: dict[str, dict]) -> dict[str, dict]:
"""Rank all candidates by weighted_rs and assign percentile-based scores.
Stocks with weighted_rs=None are excluded from percentile ranking and
assigned score=0, rs_percentile=0. Small populations (fewer than
MIN_POPULATION_FOR_FULL_SCORE valid stocks) have their scores capped
to prevent inflated rankings.
Args:
rs_results: {symbol: {score, weighted_rs, ...}} for each candidate
Returns:
Updated dict with rs_percentile and recalculated score for each symbol
"""
if not rs_results:
return {}
# Separate valid (weighted_rs is not None) from invalid
valid_symbols = [s for s in rs_results if rs_results[s].get("weighted_rs") is not None]
invalid_symbols = [s for s in rs_results if rs_results[s].get("weighted_rs") is None]
# Handle invalid stocks: score=0, rs_percentile=0
result = {}
for sym in invalid_symbols:
updated = dict(rs_results[sym])
updated["rs_percentile"] = 0
updated["score"] = 0
result[sym] = updated
if not valid_symbols:
return result
# Sort valid symbols by weighted_rs
valid_symbols.sort(key=lambda s: rs_results[s]["weighted_rs"])
n = len(valid_symbols)
# Assign percentiles (handle ties by giving same percentile)
percentiles = {}
i = 0
while i < n:
current_val = rs_results[valid_symbols[i]]["weighted_rs"]
j = i + 1
while j < n and rs_results[valid_symbols[j]]["weighted_rs"] == current_val:
j += 1
pct = int(round(j / n * 100))
for k in range(i, j):
percentiles[valid_symbols[k]] = pct
i = j
# Small population cap: with fewer valid stocks, cap score and percentile
max_score = _small_population_max_score(n)
max_percentile = _score_to_max_percentile(max_score)
for sym in valid_symbols:
updated = dict(rs_results[sym])
capped_pct = min(percentiles[sym], max_percentile)
updated["rs_percentile"] = capped_pct
updated["score"] = _percentile_to_score(capped_pct)
result[sym] = updated
return result
# Minimum population for unrestricted percentile scoring
MIN_POPULATION_FOR_FULL_SCORE = 20
def _small_population_max_score(n: int) -> int:
"""Cap maximum RS score when population is too small for reliable percentiles."""
if n >= MIN_POPULATION_FOR_FULL_SCORE:
return 100
if n >= 10:
return 90
if n >= 5:
return 80
return 70
def _score_to_max_percentile(max_score: int) -> int:
"""Return the highest percentile that maps to max_score via _percentile_to_score.
This ensures rs_percentile and score stay consistent when capped.
"""
if max_score >= 100:
return 100
if max_score >= 90:
return 94 # _percentile_to_score(94) == 90
if max_score >= 80:
return 84 # _percentile_to_score(84) == 80
if max_score >= 70:
return 74 # _percentile_to_score(74) == 70
if max_score >= 60:
return 59
if max_score >= 50:
return 44
if max_score >= 40:
return 29
return 14
def _percentile_to_score(percentile: int) -> int:
"""Map percentile rank to RS score."""
if percentile >= 95:
return 100
elif percentile >= 85:
return 90
elif percentile >= 75:
return 80
elif percentile >= 60:
return 70
elif percentile >= 45:
return 60
elif percentile >= 30:
return 50
elif percentile >= 15:
return 40
else:
return 20
def _score_rs(weighted_rs: float) -> tuple:
"""Score based on weighted relative strength."""
if weighted_rs >= 50:
return 100, 99
elif weighted_rs >= 30:
return 95, 95
elif weighted_rs >= 20:
return 90, 90
elif weighted_rs >= 10:
return 80, 80
elif weighted_rs >= 5:
return 70, 70
elif weighted_rs >= 0:
return 60, 60
elif weighted_rs >= -5:
return 50, 50
elif weighted_rs >= -10:
return 40, 40
elif weighted_rs >= -20:
return 20, 25
else:
return 0, 10
#!/usr/bin/env python3
"""
Trend Template Calculator - Minervini's 7-Point Stage 2 Filter
Evaluates whether a stock meets Minervini's Stage 2 uptrend criteria.
This is the primary gate filter - stocks must pass this to be evaluated for VCP.
The 7-Point Trend Template:
1. Price > 150-day SMA AND Price > 200-day SMA
2. 150-day SMA > 200-day SMA
3. 200-day SMA trending up for at least 22 trading days (1 month)
4. Price > 50-day SMA
5. Price at least 25% above 52-week low
6. Price within 25% of 52-week high
7. Relative Strength rating > 70 (estimated)
Scoring: Each criterion = 14.3 points (7 x 14.3 = ~100)
Pass threshold: >= 85 (must meet at least 6 of 7 criteria)
"""
from typing import Optional
def calculate_trend_template(
historical_prices: list[dict],
quote_data: dict,
rs_rank: Optional[int] = None,
ext_threshold: float = 8.0,
max_sma200_extension: float = 50.0,
) -> dict:
"""
Evaluate stock against Minervini's 7-point Trend Template.
Args:
historical_prices: Daily OHLCV data (most recent first), need 200+ days
quote_data: Current quote with price, yearHigh, yearLow
rs_rank: Pre-calculated RS rank estimate (0-99). If None, criterion 7 is skipped.
Returns:
Dict with score (0-100), criteria details, pass/fail status
"""
if not historical_prices or len(historical_prices) < 50:
return {
"score": 0,
"passed": False,
"criteria": {},
"error": "Insufficient historical data (need 50+ days)",
}
closes = [d.get("close", d.get("adjClose", 0)) for d in historical_prices]
price = quote_data.get("price", closes[0] if closes else 0)
year_high = quote_data.get("yearHigh", 0)
year_low = quote_data.get("yearLow", 0)
criteria = {}
points_per_criterion = 14.3
# Criterion 1: Price > SMA150 AND Price > SMA200
sma150 = _sma(closes, 150)
sma200 = _sma(closes, 200)
c1_pass = False
if sma150 is not None and sma200 is not None:
c1_pass = price > sma150 and price > sma200
elif sma150 is not None:
c1_pass = price > sma150
if sma150 is not None:
c1_detail = f"Price ${price:.2f} vs SMA150 ${sma150:.2f}"
if sma200 is not None:
c1_detail += f" / SMA200 ${sma200:.2f}"
else:
c1_detail = "Insufficient data for SMA150"
criteria["c1_price_above_sma150_200"] = {
"passed": c1_pass,
"detail": c1_detail,
}
# Criterion 2: SMA150 > SMA200
c2_pass = False
if sma150 is not None and sma200 is not None:
c2_pass = sma150 > sma200
criteria["c2_sma150_above_sma200"] = {
"passed": c2_pass,
"detail": f"SMA150 ${sma150:.2f} vs SMA200 ${sma200:.2f}"
if sma150 and sma200
else "Insufficient data",
}
# Criterion 3: SMA200 trending up for 22+ trading days
c3_pass = False
if len(closes) >= 222 and sma200 is not None:
sma200_22d_ago = _sma(closes[22:], 200)
if sma200_22d_ago is not None:
c3_pass = sma200 > sma200_22d_ago
criteria["c3_sma200_trending_up"] = {
"passed": c3_pass,
"detail": f"SMA200 today ${sma200:.2f} vs 22d ago ${sma200_22d_ago:.2f}",
}
else:
criteria["c3_sma200_trending_up"] = {
"passed": False,
"detail": "Insufficient data for 22d SMA200 comparison",
}
elif sma200 is not None and len(closes) >= 200:
# Not enough data to compute SMA200 from 22 days ago - fail conservatively
c3_pass = False
criteria["c3_sma200_trending_up"] = {
"passed": c3_pass,
"detail": f"Cannot verify 22d SMA200 trend (only {len(closes)} days available, need 222+)",
}
else:
criteria["c3_sma200_trending_up"] = {
"passed": False,
"detail": "Insufficient data",
}
# Criterion 4: Price > SMA50
sma50 = _sma(closes, 50)
c4_pass = False
if sma50 is not None:
c4_pass = price > sma50
criteria["c4_price_above_sma50"] = {
"passed": c4_pass,
"detail": f"Price ${price:.2f} vs SMA50 ${sma50:.2f}" if sma50 else "Insufficient data",
}
# Criterion 5: Price at least 25% above 52-week low
c5_pass = False
if year_low > 0:
pct_above_low = (price - year_low) / year_low * 100
c5_pass = pct_above_low >= 25
criteria["c5_25pct_above_52w_low"] = {
"passed": c5_pass,
"detail": f"{pct_above_low:.1f}% above 52w low ${year_low:.2f} (need >= 25%)",
}
else:
criteria["c5_25pct_above_52w_low"] = {
"passed": False,
"detail": "52-week low data unavailable",
}
# Criterion 6: Price within 25% of 52-week high
c6_pass = False
if year_high > 0:
pct_below_high = (year_high - price) / year_high * 100
c6_pass = pct_below_high <= 25
criteria["c6_within_25pct_52w_high"] = {
"passed": c6_pass,
"detail": f"{pct_below_high:.1f}% below 52w high ${year_high:.2f} (need <= 25%)",
}
else:
criteria["c6_within_25pct_52w_high"] = {
"passed": False,
"detail": "52-week high data unavailable",
}
# Criterion 7: RS Rating > 70
c7_pass = False
if rs_rank is not None:
c7_pass = rs_rank > 70
criteria["c7_rs_rank_above_70"] = {
"passed": c7_pass,
"detail": f"RS Rank: {rs_rank} (need > 70)",
}
else:
criteria["c7_rs_rank_above_70"] = {
"passed": False,
"detail": "RS Rank not yet calculated",
}
# Raw score from 7 criteria (gate判定用)
passed_count = sum(1 for c in criteria.values() if c["passed"])
raw_score = round(passed_count * points_per_criterion, 1)
raw_score = min(100, raw_score)
# Pass threshold: 85+ (6/7 criteria) - uses RAW score only
passed = raw_score >= 85
# Extended penalty: deduct for price too far above SMA50 (ranking用)
extended_penalty, sma50_distance_pct = _calculate_extended_penalty(
price, sma50, base_threshold=ext_threshold
)
# SMA200 extension penalty: deduct for price too far above SMA200
sma200_penalty, sma200_distance_pct = _calculate_sma200_penalty(
price, sma200, max_extension=max_sma200_extension
)
# SMA200 penalty excluded from score to avoid double-penalizing
# with execution_state (which already uses sma200_distance_pct for
# Overextended classification). SMA200 penalty kept as metadata.
score = max(0, raw_score + extended_penalty)
return {
"score": score,
"raw_score": raw_score,
"passed": passed,
"extended_penalty": extended_penalty,
"sma200_penalty": sma200_penalty,
"sma50_distance_pct": round(sma50_distance_pct, 2)
if sma50_distance_pct is not None
else None,
"sma200_distance_pct": round(sma200_distance_pct, 2)
if sma200_distance_pct is not None
else None,
"criteria_passed": passed_count,
"criteria_total": 7,
"criteria": criteria,
"sma50": round(sma50, 2) if sma50 else None,
"sma150": round(sma150, 2) if sma150 else None,
"sma200": round(sma200, 2) if sma200 else None,
"error": None,
}
def _calculate_sma200_penalty(
price: float,
sma200: Optional[float],
max_extension: float = 50.0,
) -> tuple:
"""Calculate penalty for price extended too far above SMA200.
Penalises highly extended leaders where the uptrend is likely to pause
or mean-revert before a fresh VCP base can form.
Penalty tiers (measured from max_extension threshold, default 50%):
distance > max+20% (default >70%) → −20
distance > max+10% (default >60%) → −15
distance > max (default >50%) → −10
distance > max−10% (default >40%) → −5
distance ≤ max−10% → 0
Args:
price: Current stock price
sma200: 200-day simple moving average
max_extension: % above SMA200 where the first penalty tier starts
Returns:
(penalty: int, distance_pct: float or None)
penalty is 0 or negative.
"""
if sma200 is None or sma200 <= 0:
return 0, None
distance_pct = (price - sma200) / sma200 * 100
# No penalty when below max_extension
if distance_pct <= max_extension:
return 0, distance_pct
excess = distance_pct - max_extension
if excess >= 20:
return -20, distance_pct
elif excess >= 10:
return -15, distance_pct
else:
return -10, distance_pct
def _calculate_extended_penalty(
price: float, sma50: Optional[float], base_threshold: float = 8.0
) -> tuple:
"""Calculate penalty for price extended too far above SMA 50.
Args:
price: Current stock price
sma50: 50-day simple moving average
base_threshold: Distance % where penalty starts (default 8.0)
Returns:
(penalty: int, distance_pct: float or None)
penalty is 0 or negative.
"""
if sma50 is None or sma50 <= 0:
return 0, None
distance_pct = (price - sma50) / sma50 * 100
if distance_pct < base_threshold:
return 0, distance_pct
excess = distance_pct - base_threshold
if excess >= 17: # base+17% (default: 25%+)
return -20, distance_pct
elif excess >= 10: # base+10% (default: 18%+)
return -15, distance_pct
elif excess >= 4: # base+4% (default: 12%+)
return -10, distance_pct
else: # base+0% (default: 8%+)
return -5, distance_pct
def _sma(prices: list[float], period: int) -> Optional[float]:
"""Calculate Simple Moving Average. Prices are most-recent-first."""
if len(prices) < period:
return None
return sum(prices[:period]) / period
#!/usr/bin/env python3
"""
VCP Pattern Calculator - Core Volatility Contraction Pattern Detection
Implements Mark Minervini's VCP detection algorithm:
1. Find swing highs and lows within a 120-day lookback window
2. Identify successive contractions (T1, T2, T3, T4)
3. Validate that each contraction is tighter than the previous
4. Score based on number of contractions, tightness, and depth ratios
VCP Characteristics:
- T1 (first correction): 8-35% depth for S&P 500 large-caps
- Each successive contraction should be 25%+ tighter than the previous
- Minimum 2 contractions required for valid VCP
- Successive highs should be within 5% of each other
- Pattern duration: 15-325 trading days
"""
from typing import Optional
def calculate_vcp_pattern(
historical_prices: list[dict],
lookback_days: int = 120,
atr_multiplier: float = 1.5,
atr_period: int = 14,
min_contraction_days: int = 5,
min_contractions: int = 2,
t1_depth_min: float = 8.0,
contraction_ratio: float = 0.75,
wide_and_loose_threshold: float = 15.0,
) -> dict:
"""
Detect Volatility Contraction Pattern in price data.
Uses ATR-based ZigZag swing detection with fallback to fixed-window method.
Tries multiple starting highs (multi-start) and selects the best pattern.
Args:
historical_prices: Daily OHLCV data (most recent first), need 30+ days
lookback_days: Number of days to look back for pattern (default 120)
atr_multiplier: ATR multiplier for ZigZag swing threshold
atr_period: ATR calculation period
min_contraction_days: Minimum days for a contraction to count
wide_and_loose_threshold: Final contraction depth % above which (combined
with <10-day duration) flags a wide-and-loose pattern (default 15.0)
Returns:
Dict with score (0-100), contractions list, pattern validity, pivot point,
atr_compression_ratio, wide_and_loose, right_side_range_ratio
"""
empty_result = {
"score": 0,
"valid_vcp": False,
"contractions": [],
"num_contractions": 0,
"pivot_price": None,
"atr_compression_ratio": None,
"wide_and_loose": False,
"right_side_range_ratio": None,
"error": None,
}
if not historical_prices or len(historical_prices) < 30:
empty_result["error"] = "Insufficient data (need 30+ days)"
return empty_result
# Work in chronological order (oldest first)
prices = list(reversed(historical_prices[:lookback_days]))
n = len(prices)
if n < 30:
empty_result["error"] = "Insufficient data in lookback window"
return empty_result
# Extract price arrays
highs = [d.get("high", d.get("close", 0)) for d in prices]
lows = [d.get("low", d.get("close", 0)) for d in prices]
closes = [d.get("close", 0) for d in prices]
dates = [d.get("date", f"day-{i}") for i, d in enumerate(prices)]
# Step A: Find swing points using ZigZag (primary) with fixed-window fallback
atr_val = _calculate_atr(highs, lows, closes, atr_period)
atr_10 = _calculate_atr(highs, lows, closes, 10)
atr_50 = _calculate_atr(highs, lows, closes, 50)
zz_highs, zz_lows = _zigzag_swing_points(highs, lows, closes, dates, atr_multiplier, atr_period)
# Use ZigZag results if they have enough points, otherwise fallback
if len(zz_highs) >= 1 and len(zz_lows) >= 1:
swing_highs = zz_highs
swing_lows = zz_lows
else:
swing_highs = _find_swing_highs(highs, window=5)
swing_lows = _find_swing_lows(lows, window=5)
if len(swing_highs) < 1 or len(swing_lows) < 1:
empty_result["error"] = "Insufficient swing points detected"
return empty_result
# Step B: Multi-start contraction detection
# Try top 3 swing highs as starting points, pick the best pattern
sorted_highs = sorted(swing_highs, key=lambda x: x[1], reverse=True)
best_contractions = []
best_score = -1
best_valid = False
for start_high in sorted_highs[:3]:
candidate = _build_contractions_from(
start_high,
swing_highs,
swing_lows,
highs,
lows,
dates,
min_contraction_days=min_contraction_days,
)
if len(candidate) >= min_contractions:
v = _validate_vcp(candidate, n, min_contractions, t1_depth_min, contraction_ratio)
s = _score_vcp(candidate, v)
else:
v = {"valid": False}
s = 0
# Compare: valid first, then score, then length as tiebreaker
key = (int(v.get("valid", False)), s, len(candidate))
best_key = (int(best_valid), best_score, len(best_contractions))
if key > best_key:
best_contractions = candidate
best_score = s
best_valid = v.get("valid", False)
contractions = best_contractions
if len(contractions) < min_contractions:
atr_compression_ratio = (atr_10 / atr_50) if (atr_50 > 0 and atr_10 > 0) else None
return {
"score": 0,
"valid_vcp": False,
"contractions": contractions,
"num_contractions": len(contractions),
"pivot_price": _get_pivot_price(contractions, highs, swing_highs),
"atr_value": round(atr_val, 4) if atr_val else None,
"atr_compression_ratio": round(atr_compression_ratio, 3)
if atr_compression_ratio is not None
else None,
"wide_and_loose": False,
"right_side_range_ratio": None,
"error": f"Fewer than {min_contractions} contractions found",
}
# Step C: Validate VCP
validation = _validate_vcp(contractions, n, min_contractions, t1_depth_min, contraction_ratio)
# Pivot price = high of the last contraction
pivot_price = _get_pivot_price(contractions, highs, swing_highs)
# Calculate pattern duration
first_idx = contractions[0]["high_idx"]
last_low_idx = contractions[-1]["low_idx"]
pattern_duration = last_low_idx - first_idx
# Score the pattern
score = _score_vcp(contractions, validation)
# ATR compression ratio: recent ATR(10) / ATR(50) — lower = more compressed
atr_compression_ratio: Optional[float] = None
if atr_10 > 0 and atr_50 > 0:
atr_compression_ratio = atr_10 / atr_50
# Wide-and-loose flag: final contraction is deep AND very short
wide_and_loose = _compute_wide_and_loose(contractions, wide_and_loose_threshold)
# Right-side tightness: 15-bar price range / ATR(50)
# Measures how compact the right side of the base is (lower = tighter)
right_side_range_ratio: Optional[float] = None
if atr_50 > 0 and n >= 15:
recent_range = max(highs[-15:]) - min(lows[-15:])
right_side_range_ratio = recent_range / atr_50
return {
"score": score,
"valid_vcp": validation["valid"],
"contractions": contractions,
"num_contractions": len(contractions),
"pivot_price": round(pivot_price, 2) if pivot_price else None,
"pattern_duration_days": pattern_duration,
"validation": validation,
"atr_value": round(atr_val, 4) if atr_val else None,
"atr_compression_ratio": round(atr_compression_ratio, 3)
if atr_compression_ratio is not None
else None,
"wide_and_loose": wide_and_loose,
"right_side_range_ratio": round(right_side_range_ratio, 3)
if right_side_range_ratio is not None
else None,
"error": None,
}
def _calculate_atr(
highs: list[float],
lows: list[float],
closes: list[float],
period: int = 14,
) -> float:
"""Calculate Average True Range.
Args:
highs: High prices in chronological order (oldest first)
lows: Low prices in chronological order
closes: Close prices in chronological order
period: ATR period (default 14)
Returns:
ATR value, or 0.0 if insufficient data
"""
n = len(highs)
if n < period + 1:
return 0.0
true_ranges = []
for i in range(1, n):
tr = max(
highs[i] - lows[i],
abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1]),
)
true_ranges.append(tr)
if len(true_ranges) < period:
return 0.0
# Simple moving average of true ranges for the last `period` values
return sum(true_ranges[-period:]) / period
def _zigzag_swing_points(
highs: list[float],
lows: list[float],
closes: list[float],
dates: list[str],
atr_multiplier: float = 1.5,
atr_period: int = 14,
) -> tuple:
"""ATR-based ZigZag swing detection.
Reversal is recognized only when price moves ATR * multiplier from
the current extreme. This filters out noise while adapting to volatility.
Args:
highs: High prices (chronological, oldest first)
lows: Low prices (chronological, oldest first)
closes: Close prices (chronological, oldest first)
dates: Date strings (chronological, oldest first)
atr_multiplier: Multiplier for ATR threshold
atr_period: ATR calculation period
Returns:
(swing_highs: [(idx, val)], swing_lows: [(idx, val)])
"""
n = len(highs)
if n < atr_period + 1:
return [], []
atr = _calculate_atr(highs, lows, closes, atr_period)
if atr <= 0:
return [], []
threshold = atr * atr_multiplier
swing_highs = []
swing_lows = []
# Start by finding initial direction from first few bars
direction = 1 # 1 = looking for high, -1 = looking for low
extreme_idx = 0
extreme_val = highs[0]
# Find initial extreme
for i in range(min(atr_period, n)):
if highs[i] > extreme_val:
extreme_val = highs[i]
extreme_idx = i
direction = 1 # Start looking for a swing high
extreme_val = highs[extreme_idx]
for i in range(extreme_idx + 1, n):
if direction == 1: # Looking for swing high
if highs[i] > extreme_val:
extreme_val = highs[i]
extreme_idx = i
elif extreme_val - lows[i] >= threshold:
# Swing high confirmed at extreme_idx
swing_highs.append((extreme_idx, extreme_val))
# Start looking for swing low
direction = -1
extreme_val = lows[i]
extreme_idx = i
else: # direction == -1, looking for swing low
if lows[i] < extreme_val:
extreme_val = lows[i]
extreme_idx = i
elif highs[i] - extreme_val >= threshold:
# Swing low confirmed at extreme_idx
swing_lows.append((extreme_idx, extreme_val))
# Start looking for swing high
direction = 1
extreme_val = highs[i]
extreme_idx = i
return swing_highs, swing_lows
def _find_swing_highs(highs: list[float], window: int = 5) -> list[tuple[int, float]]:
"""Find swing high points using fixed window. Returns list of (index, value)."""
swing_highs = []
for i in range(window, len(highs) - window):
is_high = True
for j in range(1, window + 1):
if highs[i] <= highs[i - j] or highs[i] <= highs[i + j]:
is_high = False
break
if is_high:
swing_highs.append((i, highs[i]))
return swing_highs
def _find_swing_lows(lows: list[float], window: int = 5) -> list[tuple[int, float]]:
"""Find swing low points using fixed window. Returns list of (index, value)."""
swing_lows = []
for i in range(window, len(lows) - window):
is_low = True
for j in range(1, window + 1):
if lows[i] >= lows[i - j] or lows[i] >= lows[i + j]:
is_low = False
break
if is_low:
swing_lows.append((i, lows[i]))
return swing_lows
def _identify_contractions(
swing_highs: list[tuple[int, float]],
swing_lows: list[tuple[int, float]],
highs: list[float],
lows: list[float],
dates: list[str],
) -> list[dict]:
"""
Identify successive contractions from swing points.
Each contraction is defined by a swing high followed by a swing low.
"""
if not swing_highs:
return []
# Start from the highest swing high in the lookback
h1_idx, h1_val = max(swing_highs, key=lambda x: x[1])
contractions = []
current_high_idx = h1_idx
current_high_val = h1_val
# Find successive contraction pairs
for _ in range(4): # Max 4 contractions
# Find next swing low after current high
next_low = None
for idx, val in swing_lows:
if idx > current_high_idx:
next_low = (idx, val)
break
if next_low is None:
break
low_idx, low_val = next_low
depth_pct = (
(current_high_val - low_val) / current_high_val * 100 if current_high_val > 0 else 0
)
contractions.append(
{
"label": f"T{len(contractions) + 1}",
"high_idx": current_high_idx,
"high_price": round(current_high_val, 2),
"high_date": dates[current_high_idx] if current_high_idx < len(dates) else "N/A",
"low_idx": low_idx,
"low_price": round(low_val, 2),
"low_date": dates[low_idx] if low_idx < len(dates) else "N/A",
"depth_pct": round(depth_pct, 2),
}
)
# Find next swing high after this low (for the next contraction)
next_high = None
for idx, val in swing_highs:
if idx > low_idx:
next_high = (idx, val)
break
if next_high is None:
break
current_high_idx, current_high_val = next_high
return contractions
def _build_contractions_from(
start_high: tuple[int, float],
swing_highs: list[tuple[int, float]],
swing_lows: list[tuple[int, float]],
highs: list[float],
lows: list[float],
dates: list[str],
min_contraction_days: int = 5,
) -> list[dict]:
"""Build contraction sequence from a specific swing high starting point.
Args:
start_high: (index, value) of starting swing high
swing_highs: All swing highs
swing_lows: All swing lows
highs: All high prices
lows: All low prices
dates: All dates
min_contraction_days: Minimum days between high and low for a contraction
"""
h1_idx, h1_val = start_high
contractions = []
current_high_idx = h1_idx
current_high_val = h1_val
for _ in range(4): # Max 4 contractions
# Find next swing low after current high
next_low = None
for idx, val in swing_lows:
if idx > current_high_idx:
next_low = (idx, val)
break
if next_low is None:
break
low_idx, low_val = next_low
duration = low_idx - current_high_idx
# Skip contractions that are too short
if duration < min_contraction_days:
# Find the next swing high after current_high to bound the search
next_high_boundary = None
for idx, val in swing_highs:
if idx > current_high_idx:
next_high_boundary = idx
break
# Try to find a later swing low, but only before the next swing high
found_valid = False
for idx, val in swing_lows:
if idx > current_high_idx and (idx - current_high_idx) >= min_contraction_days:
if next_high_boundary is not None and idx > next_high_boundary:
break # Don't jump past an intermediate swing high
next_low = (idx, val)
low_idx, low_val = next_low
duration = low_idx - current_high_idx
found_valid = True
break
if not found_valid:
break
depth_pct = (
(current_high_val - low_val) / current_high_val * 100 if current_high_val > 0 else 0
)
# Right-shoulder validation: subsequent highs within 5% of H1
if contractions:
pct_from_h1 = abs(current_high_val - h1_val) / h1_val * 100
if pct_from_h1 > 5:
break
contractions.append(
{
"label": f"T{len(contractions) + 1}",
"high_idx": current_high_idx,
"high_price": round(current_high_val, 2),
"high_date": dates[current_high_idx] if current_high_idx < len(dates) else "N/A",
"low_idx": low_idx,
"low_price": round(low_val, 2),
"low_date": dates[low_idx] if low_idx < len(dates) else "N/A",
"depth_pct": round(depth_pct, 2),
"duration_days": duration,
}
)
# Find next swing high after this low
next_high = None
for idx, val in swing_highs:
if idx > low_idx:
next_high = (idx, val)
break
if next_high is None:
break
current_high_idx, current_high_val = next_high
return contractions
def _validate_vcp(
contractions: list[dict],
total_days: int,
min_contractions: int = 2,
t1_depth_min: float = 8.0,
contraction_ratio: float = 0.75,
) -> dict:
"""Validate whether the contraction pattern qualifies as a VCP."""
issues = []
valid = True
if len(contractions) < min_contractions:
return {"valid": False, "issues": [f"Need at least {min_contractions} contractions"]}
# Check T1 depth (8-35% for large-caps)
t1_depth = contractions[0]["depth_pct"]
if t1_depth < t1_depth_min:
issues.append(f"T1 depth too shallow ({t1_depth:.1f}%, need >= {t1_depth_min}%)")
valid = False
elif t1_depth > 35:
issues.append(f"T1 depth too deep ({t1_depth:.1f}%, prefer <= 35%)")
# Don't invalidate, just flag
# Check contraction tightening (each T should be <= 75% of previous)
contraction_ratios = []
for i in range(1, len(contractions)):
prev_depth = contractions[i - 1]["depth_pct"]
curr_depth = contractions[i]["depth_pct"]
if prev_depth > 0:
ratio = curr_depth / prev_depth
contraction_ratios.append(ratio)
if ratio > contraction_ratio:
issues.append(
f"{contractions[i]['label']} ({curr_depth:.1f}%) does not contract "
f"vs {contractions[i - 1]['label']} ({prev_depth:.1f}%), "
f"ratio={ratio:.2f} (need <= {contraction_ratio})"
)
valid = False
# Check successive highs within 5% of each other
for i in range(1, len(contractions)):
prev_high = contractions[i - 1]["high_price"]
curr_high = (
contractions[i]["high_price"]
if i < len(contractions)
else contractions[-1]["high_price"]
)
# The high of subsequent contraction should be near the first
if prev_high > 0:
pct_diff = (
abs(curr_high - contractions[0]["high_price"]) / contractions[0]["high_price"] * 100
)
if pct_diff > 5:
issues.append(
f"{contractions[i]['label']} high ${curr_high:.2f} is "
f"{pct_diff:.1f}% from H1 ${contractions[0]['high_price']:.2f}"
)
# Pattern duration check (15-325 trading days)
if len(contractions) >= 2:
duration = contractions[-1]["low_idx"] - contractions[0]["high_idx"]
if duration < 15:
issues.append(f"Pattern too short ({duration} days, need >= 15)")
valid = False
elif duration > 325:
issues.append(f"Pattern too long ({duration} days, prefer <= 325)")
return {
"valid": valid,
"issues": issues,
"contraction_ratios": [round(r, 3) for r in contraction_ratios],
"t1_depth": t1_depth,
}
def _compute_wide_and_loose(contractions: list[dict], threshold: float) -> bool:
"""Return True if the final contraction is wide-and-loose.
Wide-and-loose: depth > threshold AND duration < 10 days.
This flags patterns where the final consolidation is too deep and
too brief to be a quality VCP setup.
Args:
contractions: List of contraction dicts (must have depth_pct, duration_days)
threshold: Maximum acceptable depth % (final contraction)
Returns:
True if final contraction qualifies as wide-and-loose
"""
if not contractions:
return False
final = contractions[-1]
final_depth = final.get("depth_pct", 0.0)
final_duration = final.get("duration_days", 999)
return final_depth > threshold and final_duration < 10
def _get_pivot_price(
contractions: list[dict],
highs: list[float],
swing_highs: list[tuple[int, float]],
) -> Optional[float]:
"""Get the pivot (breakout) price - high of the last contraction."""
if contractions:
return contractions[-1]["high_price"]
elif swing_highs:
return swing_highs[-1][1]
return None
def _score_vcp(contractions: list[dict], validation: dict) -> int:
"""Score the VCP pattern quality (0-100)."""
if not validation["valid"]:
# Even invalid patterns get partial credit for structure
return min(40, len(contractions) * 15)
num = len(contractions)
# Base score by contraction count
if num >= 4:
base = 90
elif num >= 3:
base = 80
elif num >= 2:
base = 60
else:
return 20
score = base
# Bonus: tight final contraction (< 5% depth)
final_depth = contractions[-1]["depth_pct"]
if final_depth < 5:
score += 10
# Bonus: good contraction ratio (avg < 0.4 of T1)
ratios = validation.get("contraction_ratios", [])
if ratios and sum(ratios) / len(ratios) < 0.4:
score += 10
# Penalty: deep T1 (> 30%)
t1_depth = validation.get("t1_depth", 0)
if t1_depth > 30:
score -= 10
return max(0, min(100, score))
#!/usr/bin/env python3
"""
Volume Pattern Calculator - Volume Dry-Up Analysis
Analyzes volume behavior near the pivot point of a VCP pattern.
Key principle: Volume should contract (dry up) as the pattern tightens,
then expand on breakout.
Key Metric: Volume dry-up ratio = avg volume (10 bars before pivot, bar[0] excluded)
/ 50-day avg volume
Scoring:
- Dry-up ratio < 0.30: 90 (exceptional volume contraction)
- 0.30-0.50: 75 (strong dry-up)
- 0.50-0.70: 60 (moderate dry-up)
- 0.70-1.00: 40 (weak dry-up)
- > 1.00: 20 (no dry-up, not ideal)
Modifiers:
- Breakout on 1.5x+ volume: +10
- Net accumulation > 3 days: +10
- Net distribution > 3 days: -10
- Declining contraction volume: +10
Note: Bar[0] (potential breakout bar) is excluded from dry-up calculation
to avoid contaminating the dry-up ratio with high breakout volume.
The breakout quality is tracked separately via breakout_volume_score.
"""
from typing import Optional
def calculate_volume_pattern(
historical_prices: list[dict],
pivot_price: Optional[float] = None,
contractions: Optional[list[dict]] = None,
breakout_volume_ratio: float = 1.5,
) -> dict:
"""
Analyze volume behavior near the VCP pivot point.
When contractions are provided, uses zone-based analysis:
- Zone A: Last contraction period (volume during tightening)
- Zone B: Pivot approach (5-10 bars before pivot)
- Zone C: Breakout bar (price above pivot on high volume)
When contractions is None or empty, uses legacy 10-bar window.
Args:
historical_prices: Daily OHLCV data (most recent first), need 50+ days
pivot_price: The pivot (breakout) price level. If None, uses recent high.
contractions: List of contraction dicts with high_idx/low_idx (chronological)
Returns:
Dict with score (0-100), dry_up_ratio, volume details
"""
if not historical_prices or len(historical_prices) < 20:
return {
"score": 0,
"dry_up_ratio": None,
"error": "Insufficient data (need 20+ days)",
}
volumes = [d.get("volume", 0) for d in historical_prices]
closes = [d.get("close", d.get("adjClose", 0)) for d in historical_prices]
# 50-day average volume (or available)
vol_period = min(50, len(volumes))
avg_volume_50d = sum(volumes[:vol_period]) / vol_period if vol_period > 0 else 0
if avg_volume_50d <= 0:
return {
"score": 0,
"dry_up_ratio": None,
"error": "No volume data available",
}
# Zone-based analysis when contractions are provided
zone_analysis = None
contraction_volume_trend = None
use_zone = contractions is not None and len(contractions) >= 1
if use_zone:
zone_analysis, contraction_volume_trend = _zone_volume_analysis(
volumes, closes, contractions, pivot_price, avg_volume_50d
)
# Dry-up ratio: use Zone B if available, otherwise legacy window.
# Bar[0] (potential breakout bar) is excluded from both paths to avoid
# contaminating dry-up with high breakout volume.
if use_zone and zone_analysis and zone_analysis.get("zone_b_avg_volume"):
avg_volume_recent = zone_analysis["zone_b_avg_volume"]
else:
# volumes[1:11] — 10 bars, skip bar[0]
legacy_vols = volumes[1:11] if len(volumes) > 1 else []
avg_volume_recent = sum(legacy_vols) / len(legacy_vols) if legacy_vols else 0
dry_up_ratio = avg_volume_recent / avg_volume_50d if avg_volume_50d > 0 else 1.0
# Base score from dry-up ratio
if dry_up_ratio < 0.30:
base_score = 90
elif dry_up_ratio < 0.50:
base_score = 75
elif dry_up_ratio < 0.70:
base_score = 60
elif dry_up_ratio <= 1.00:
base_score = 40
else:
base_score = 20
score = base_score
# Modifier: Breakout volume confirmation (bar[0])
# Tracked independently from dry-up — high breakout volume on a clean
# bar[0] above pivot is a positive signal, not a contaminator of dry-up.
breakout_volume = False
breakout_volume_score = 0
current_price = closes[0] if closes else 0
if len(volumes) >= 1 and avg_volume_50d > 0:
bar0_ratio = volumes[0] / avg_volume_50d
if pivot_price and current_price > pivot_price:
if bar0_ratio >= breakout_volume_ratio:
breakout_volume = True
score += 10
# Independent breakout volume score regardless of pivot position
if bar0_ratio >= 3.0:
breakout_volume_score = 100
elif bar0_ratio >= 2.0:
breakout_volume_score = 80
elif bar0_ratio >= breakout_volume_ratio:
breakout_volume_score = 60
elif bar0_ratio >= 1.0:
breakout_volume_score = 30
# Modifier: Net accumulation/distribution in last 20 days
# Only count days where volume exceeds 50-day average (institutional activity)
up_vol_days = 0
down_vol_days = 0
analysis_period = min(20, len(closes) - 1)
for i in range(analysis_period):
if i + 1 < len(closes) and volumes[i] > avg_volume_50d:
if closes[i] > closes[i + 1]:
up_vol_days += 1
elif closes[i] < closes[i + 1]:
down_vol_days += 1
net_accumulation = up_vol_days - down_vol_days
if net_accumulation > 3:
score += 10
elif net_accumulation < -3:
score -= 10
# Zone bonus: declining contraction volume (strengthened +5 → +10)
if contraction_volume_trend and contraction_volume_trend.get("declining"):
score += 10
score = max(0, min(100, score))
result = {
"score": score,
"dry_up_ratio": round(dry_up_ratio, 3),
"avg_volume_50d": int(avg_volume_50d),
"avg_volume_recent_10d": int(avg_volume_recent),
"breakout_volume_detected": breakout_volume,
"breakout_volume_score": breakout_volume_score,
"up_volume_days_20d": up_vol_days,
"down_volume_days_20d": down_vol_days,
"net_accumulation": net_accumulation,
"error": None,
}
if zone_analysis is not None:
result["zone_analysis"] = zone_analysis
if contraction_volume_trend is not None:
result["contraction_volume_trend"] = contraction_volume_trend
return result
def _zone_volume_analysis(
volumes: list[int],
closes: list[float],
contractions: list[dict],
pivot_price: Optional[float],
avg_volume_50d: float,
) -> tuple:
"""Perform zone-based volume analysis using contraction boundaries.
Data is most-recent-first. Contraction indices are chronological (oldest-first).
We convert contraction indices to most-recent-first by: rev_idx = n - 1 - chrono_idx
Returns:
(zone_analysis dict, contraction_volume_trend dict)
"""
n = len(volumes)
# Zone A: Last contraction period
last_c = contractions[-1]
# Convert chronological indices to most-recent-first
zone_a_start_rev = n - 1 - last_c["low_idx"]
zone_a_end_rev = n - 1 - last_c["high_idx"]
zone_a_start = min(zone_a_start_rev, zone_a_end_rev)
zone_a_end = max(zone_a_start_rev, zone_a_end_rev)
zone_a_vols = volumes[max(0, zone_a_start) : min(n, zone_a_end + 1)]
zone_a_avg = int(sum(zone_a_vols) / len(zone_a_vols)) if zone_a_vols else 0
# Zone B: Pivot approach (10 bars before current, bar[0] excluded)
# volumes[1:11] = bars 1..10 (10 bars) — breakout bar excluded
zone_b_start = 1 # skip bar 0 (potential breakout)
zone_b_end = min(11, n)
zone_b_vols = volumes[zone_b_start:zone_b_end]
zone_b_avg = int(sum(zone_b_vols) / len(zone_b_vols)) if zone_b_vols else 0
# Zone C: Breakout bar (bar 0 if price > pivot)
zone_c_vol = None
zone_c_ratio = None
if pivot_price and n > 0 and closes[0] > pivot_price:
zone_c_vol = volumes[0]
zone_c_ratio = round(zone_c_vol / avg_volume_50d, 3) if avg_volume_50d > 0 else None
zone_analysis = {
"zone_a_avg_volume": zone_a_avg,
"zone_a_ratio": round(zone_a_avg / avg_volume_50d, 3) if avg_volume_50d > 0 else None,
"zone_b_avg_volume": zone_b_avg,
"zone_b_ratio": round(zone_b_avg / avg_volume_50d, 3) if avg_volume_50d > 0 else None,
"zone_c_volume": zone_c_vol,
"zone_c_ratio": zone_c_ratio,
}
# Contraction volume trend: check if volume declines across contractions
contraction_avgs = []
for c in contractions:
c_start_rev = n - 1 - c["low_idx"]
c_end_rev = n - 1 - c["high_idx"]
c_start = min(c_start_rev, c_end_rev)
c_end = max(c_start_rev, c_end_rev)
c_vols = volumes[max(0, c_start) : min(n, c_end + 1)]
if c_vols:
contraction_avgs.append(int(sum(c_vols) / len(c_vols)))
declining = False
if len(contraction_avgs) >= 2:
declining = all(
contraction_avgs[i] > contraction_avgs[i + 1] for i in range(len(contraction_avgs) - 1)
)
contraction_volume_trend = {
"declining": declining,
"contraction_volumes": contraction_avgs,
}
return zone_analysis, contraction_volume_trend
#!/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 VCP Screener
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
- S&P 500 constituents fetching
"""
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)
try:
from _fmp_compat import v3_to_stable
except ModuleNotFoundError: # loaded by file path (e.g. repo-level contract tests)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fmp_compat import v3_to_stable
# --- 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_sp500_constituents(self) -> Optional[list[dict]]:
"""Fetch S&P 500 constituent list.
Returns:
List of dicts with keys: symbol, name, sector, subSector
or None on failure.
"""
cache_key = "sp500_constituents"
if cache_key in self.cache:
return self.cache[cache_key]
# Migrate hardcoded v3 URL to /stable (this method bypasses the
# _FMP_ENDPOINTS stable→v3 fallback list, so rewrite at the call site).
url, params = v3_to_stable(f"{self.BASE_URL}/sp500_constituent")
data = self._rate_limited_get(url, params)
if data:
self.cache[cache_key] = data
return data
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 = 260) -> 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 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
"""Historical VCP report writers — JSON + Markdown timeline for a single ticker.
Schema is intentionally distinct from the cross-sectional ``report_generator.py``
output: that one is "ranked list at a single point in time", this one is
"timeline of detections with forward-outcome stats per detection".
"""
import json
def generate_historical_json_report(
symbol: str,
detections: list[dict],
metadata: dict,
output_file: str,
) -> None:
"""Write a structured JSON timeline of historical VCP detections."""
report = {
"schema_version": "1.0",
"symbol": symbol,
"metadata": metadata,
"summary": _summarize(detections),
"detections": detections,
}
with open(output_file, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, default=str)
print(f" JSON report saved to: {output_file}")
def generate_historical_markdown_report(
symbol: str,
detections: list[dict],
metadata: dict,
output_file: str,
) -> None:
"""Write a human-readable timeline of historical VCP detections."""
lines: list[str] = []
lines.append(f"# VCP History — {symbol}")
lines.append(f"**Generated:** {metadata.get('generated_at', 'N/A')}")
if metadata.get("history_range"):
lines.append(f"**History range:** {metadata['history_range']}")
lines.append(
f"**Sweep:** stride={metadata.get('stride_days', '?')}d, "
f"lookback={metadata.get('lookback_days', '?')}d, "
f"outcome_window={metadata.get('outcome_days', '?')}d"
)
lines.append("")
lines.append(
"> **Note**: `marketCap` and absolute RS percentile reflect the "
"ticker in isolation, not against the live screening universe. "
"Use this report for pattern study, not portfolio sizing."
)
lines.append("")
lines.append("---")
lines.append("")
summary = _summarize(detections)
lines.append("## Summary")
lines.append("")
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| Detections | {summary['total']} |")
lines.append(f"| Breakouts | {summary['breakouts']} |")
lines.append(f"| Stop hits | {summary['stop_hits']} |")
lines.append(f"| Timeouts | {summary['timeouts']} |")
lines.append(f"| Hit rate (breakouts / resolved) | {summary['hit_rate_pct']}% |")
lines.append(f"| Avg max gain (breakouts only) | {summary['avg_max_gain_breakout_pct']}% |")
lines.append("")
lines.append("---")
lines.append("")
if not detections:
lines.append("_No VCP detections found in the scanned history._")
with open(output_file, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f" Markdown report saved to: {output_file}")
return
lines.append("## Detection Timeline")
lines.append("")
lines.append(
"| As-of date | Score | Rating | State | Pattern | Pivot | Stop | Outcome | Days | Max gain | Max loss |"
)
lines.append(
"|------------|-------|--------|-------|---------|-------|------|---------|------|----------|----------|"
)
for det in detections:
outcome = det.get("forward_outcome", {}) or {}
vcp = det.get("vcp_pattern", {}) or {}
contractions = vcp.get("contractions") or []
stop = contractions[-1].get("low_price") if contractions else None
lines.append(
"| {as_of} | {score} | {rating} | {state} | {pat} | {pivot} | {stop} | {outcome} | {days} | {gain} | {loss} |".format(
as_of=det.get("as_of_date", "?"),
score=_fmt(det.get("composite_score"), "{:.1f}"),
rating=det.get("rating", "-"),
state=det.get("execution_state", "-"),
pat=det.get("pattern_type", "-"),
pivot=_fmt(vcp.get("pivot_price"), "${:.2f}"),
stop=_fmt(stop, "${:.2f}"),
outcome=outcome.get("outcome_type", "-"),
days=_fmt(outcome.get("days_to_outcome"), "{}"),
gain=_fmt(outcome.get("max_gain_pct"), "{:+.1f}%"),
loss=_fmt(outcome.get("max_loss_pct"), "{:+.1f}%"),
)
)
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Per-detection detail")
lines.append("")
for i, det in enumerate(detections, start=1):
vcp = det.get("vcp_pattern", {}) or {}
outcome = det.get("forward_outcome", {}) or {}
contractions = vcp.get("contractions") or []
lines.append(f"### {i}. {det.get('as_of_date', '?')} — {det.get('rating', '-')}")
lines.append("")
lines.append(
f"- **Composite score:** {_fmt(det.get('composite_score'), '{:.1f}')} "
f"({det.get('pattern_type', '-')}, state: {det.get('execution_state', '-')})"
)
lines.append(
f"- **Pivot:** {_fmt(vcp.get('pivot_price'), '${:.2f}')} · "
f"**# contractions:** {vcp.get('num_contractions', '-')} · "
f"**duration:** {vcp.get('pattern_duration_days', '-')} bars"
)
if contractions:
lines.append("- **Contractions:**")
for c in contractions:
lines.append(
f" - {c.get('label', '?')}: "
f"{c.get('high_date', '?')} ${c.get('high_price', '?')} → "
f"{c.get('low_date', '?')} ${c.get('low_price', '?')} "
f"({_fmt(c.get('depth_pct'), '{:.1f}%')}, "
f"{c.get('duration_days', '?')}d)"
)
lines.append(
f"- **Forward outcome ({outcome.get('bars_evaluated', '?')} bars):** "
f"{outcome.get('outcome_type', '-')} "
f"in {_fmt(outcome.get('days_to_outcome'), '{} days')} · "
f"max gain {_fmt(outcome.get('max_gain_pct'), '{:+.1f}%')} · "
f"max loss {_fmt(outcome.get('max_loss_pct'), '{:+.1f}%')}"
)
lines.append("")
with open(output_file, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f" Markdown report saved to: {output_file}")
def _fmt(val, template: str) -> str:
if val is None:
return "-"
try:
return template.format(val)
except (TypeError, ValueError):
return str(val)
def _summarize(detections: list[dict]) -> dict:
total = len(detections)
counts = {"breakout": 0, "stop_hit": 0, "timeout": 0, "insufficient_data": 0}
gain_sum = 0.0
gain_count = 0
for det in detections:
oc = (det.get("forward_outcome") or {}).get("outcome_type")
if oc in counts:
counts[oc] += 1
if oc == "breakout":
g = (det.get("forward_outcome") or {}).get("max_gain_pct")
if g is not None:
gain_sum += g
gain_count += 1
resolved = counts["breakout"] + counts["stop_hit"]
hit_rate = round(counts["breakout"] / resolved * 100, 1) if resolved else None
avg_gain = round(gain_sum / gain_count, 1) if gain_count else None
return {
"total": total,
"breakouts": counts["breakout"],
"stop_hits": counts["stop_hit"],
"timeouts": counts["timeout"],
"insufficient_data": counts["insufficient_data"],
"hit_rate_pct": hit_rate,
"avg_max_gain_breakout_pct": avg_gain,
}
#!/usr/bin/env python3
"""Historical VCP Scanner — walk a single ticker's price history and detect
every VCP that formed along the way.
Companion to the cross-sectional ``screen_vcp.py`` pipeline. Unlike that
pipeline (which answers "which S&P 500 names are setting up *now*"), this
scanner answers "which VCPs has this one ticker formed in the past N years,
and what happened after each one?"
Pipeline:
1. Fetch a long history (e.g. ~5 years) once.
2. Walk the as-of cursor backwards in time at ``stride_days`` (default 5).
3. At each cursor position, synthesize a quote (no future-bar peeking) and
call ``analyze_stock(..., as_of_offset=cursor)``.
4. For every ``valid_vcp=True`` detection, compute the forward outcome
(breakout / stop_hit / timeout) over the next ``outcome_days`` bars.
5. Deduplicate by (T1_high_date, last_low_date, round(pivot, 2)) so the same
pattern isn't reported repeatedly as the cursor ages.
6. Return detections in chronological order (oldest first).
"""
from __future__ import annotations
import os
import re
import sys
# Allow imports from the scripts/ directory when invoked as a module.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import screen_vcp # noqa: E402 — bound at module load so tests can monkeypatch
# `historical_scanner.screen_vcp.analyze_stock` deterministically.
from calculators.forward_outcome import calculate_forward_outcome # noqa: E402
_TICKER_RE = re.compile(r"^[A-Z][A-Z0-9.\-]{0,11}$")
def sanitize_ticker(symbol: str) -> str:
"""Validate that ``symbol`` matches an FMP-style ticker (letters, digits,
dot, hyphen; starts with a letter; up to 12 chars). Returns the uppercased
symbol. Raises ``ValueError`` on anything that could traverse a path or
inject characters into a filename.
"""
sym = (symbol or "").upper().strip()
if not _TICKER_RE.match(sym):
raise ValueError(f"Invalid ticker symbol: {symbol!r}. Must match {_TICKER_RE.pattern}")
return sym
def build_quote_from_history(
historical: list[dict],
as_of_offset: int,
year_window_bars: int = 252,
) -> dict:
"""Synthesize a quote dict compatible with ``calculate_trend_template``
using only bars at or older than ``historical[as_of_offset]``.
Contract: no bar with MRF index < ``as_of_offset`` may influence any
returned field. (Those bars are "future" relative to the as-of date.)
Returns:
Dict with at minimum ``price``, ``yearHigh``, ``yearLow``,
``avgVolume``, ``marketCap`` (defaulted to 0 — historical share count
is not retrievable from OHLCV).
"""
if not historical or as_of_offset < 0 or as_of_offset >= len(historical):
return {"price": 0, "yearHigh": 0, "yearLow": 0, "avgVolume": 0, "marketCap": 0}
as_of_bar = historical[as_of_offset]
window = historical[as_of_offset : as_of_offset + year_window_bars]
if not window:
return {"price": 0, "yearHigh": 0, "yearLow": 0, "avgVolume": 0, "marketCap": 0}
year_high = max(d.get("high", 0) for d in window)
year_low = min(d.get("low", 0) for d in window if d.get("low", 0) > 0)
vol_window = historical[as_of_offset : as_of_offset + 50]
avg_volume = (
int(sum(d.get("volume", 0) for d in vol_window) / len(vol_window)) if vol_window else 0
)
return {
"price": as_of_bar.get("close", 0),
"yearHigh": year_high,
"yearLow": year_low,
"avgVolume": avg_volume,
"marketCap": 0, # historical share count not available; deliberately stubbed.
}
def scan_history(
symbol: str,
historical: list[dict],
sp500_history: list[dict],
*,
sector: str = "Unknown",
company_name: str = "",
stride_days: int = 5,
outcome_days: int = 60,
lookback_days: int = 120,
analyzer_kwargs: dict | None = None,
) -> list[dict]:
"""Walk ``historical`` from oldest scannable bar to ``outcome_days`` ago,
detect VCPs, deduplicate, and attach forward outcomes.
Args:
symbol: Ticker symbol (passed through to ``analyze_stock``).
historical: Most-recent-first OHLCV bars (typically 5+ years).
sp500_history: Most-recent-first SPY OHLCV, aligned by index with
``historical``. Sliced identically to keep RS comparisons fair.
sector / company_name: Metadata for output.
stride_days: Step size for the as-of cursor in trading days (default 5).
outcome_days: Forward window for outcome evaluation (default 60).
lookback_days: Window passed to the VCP calculator (default 120).
analyzer_kwargs: Extra kwargs forwarded to ``analyze_stock`` (e.g.
``min_contractions``, ``t1_depth_min``, etc.).
Returns:
List of detection dicts (chronological), each shaped as the
``analyze_stock`` return value plus ``as_of_date`` and
``forward_outcome`` fields.
"""
if not historical or len(historical) < lookback_days + 30:
return []
analyzer_kwargs = dict(analyzer_kwargs or {})
analyzer_kwargs.pop("as_of_offset", None) # caller cannot override the cursor
# Largest scannable offset is len(historical) - lookback_days; smaller
# offsets get less forward data (outcomes resolve via timeout /
# insufficient_data branches). range() with a negative step already
# yields descending offsets — no extra sort needed.
max_offset = len(historical) - lookback_days
if max_offset <= 0:
return []
offsets = range(max_offset, -1, -stride_days)
seen: set[tuple[str, str, float]] = set()
detections: list[dict] = []
for offset in offsets:
quote = build_quote_from_history(historical, offset)
if quote.get("price", 0) <= 0:
continue
# Looked up via the screen_vcp module attribute so tests can monkeypatch
# historical_scanner.screen_vcp.analyze_stock to inject fake results.
result = screen_vcp.analyze_stock(
symbol,
historical,
quote,
sp500_history,
sector=sector,
company_name=company_name,
lookback_days=lookback_days,
as_of_offset=offset,
**analyzer_kwargs,
)
if result is None or not result.get("valid_vcp"):
continue
contractions = result.get("vcp_pattern", {}).get("contractions") or []
pivot = result.get("vcp_pattern", {}).get("pivot_price")
if not contractions or pivot is None:
continue
# Dedup key: identify by the first contraction's start and the last
# contraction's bottom, plus pivot (rounded). Fall back to chronological
# index strings if dates are missing so two unrelated patterns don't
# collide on the empty-string default.
first_c = contractions[0]
last_c = contractions[-1]
key = (
first_c.get("high_date") or f"idx:{first_c.get('high_idx', '')}",
last_c.get("low_date") or f"idx:{last_c.get('low_idx', '')}",
round(float(pivot), 2),
)
if key in seen:
continue
seen.add(key)
outcome = calculate_forward_outcome(
historical,
as_of_offset=offset,
pivot_price=float(pivot),
stop_price=contractions[-1].get("low_price"),
max_window_days=outcome_days,
)
result["as_of_date"] = historical[offset].get("date")
result["as_of_offset"] = offset
result["forward_outcome"] = outcome
detections.append(result)
# Sort chronologically oldest -> newest by as_of_date.
detections.sort(key=lambda r: r.get("as_of_date") or "")
return detections
Related skills
How it compares
Use vcp-screener for live VCP pattern scans via FMP; use finviz-screener for conversational FinViz filter URL generation.
FAQ
Which FMP endpoints does vcp-screener call?
vcp-screener uses FMP /api/v3/sp500_constituent, batch /api/v3/quote requests with five symbols each, and /api/v3/historical-price-full with timeseries=260 for price history on SPY and candidates.
How large is the vcp-screener stock universe?
vcp-screener defines its universe from S&P 500 constituents, about 503 stocks. It issues roughly 101 batch quote calls and evaluates up to 100 finalists with 260-day historical series.
Is Vcp Screener safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.