references/fmp_api_endpoints.md
# 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=260` parameter (260 trading days = ~1 year)
- Phase 3 (VCP detection, scoring, reporting) requires NO additional API calls
- The `--full-sp500` flag fetches histories for all pre-filter passers (~250 stocks)
references/scoring_system.md
# 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=True` stocks, sorted by composite score
- **Section B: Extended / Quality VCP** — `entry_ready=False` stocks, 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 with `valid_vcp=True` AND `execution_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 |
references/vcp_methodology.md
# 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)
P
```
### Contraction 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 |
scripts/_fmp_compat.py
"""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
scripts/calculators/__init__.py
# VCP Screener Calculators
scripts/calculators/execution_state.py
#!/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
scripts/calculators/forward_outcome.py
#!/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),
}
scripts/calculators/pattern_classifier.py
#!/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"
scripts/calculators/pivot_proximity_calculator.py
#!/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,
}
scripts/calculators/relative_strength_calculator.py
#!/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
scripts/calculators/trend_template_calculator.py
#!/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
scripts/calculators/vcp_pattern_calculator.py
#!/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))
scripts/calculators/volume_pattern_calculator.py
#!/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
scripts/fmp_client.py
#!/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)
# Public-dataset fallback for keys where no FMP tier serves constituents:
# /stable/sp500-constituent 402s (Restricted Endpoint) on the free tier
# and /api/v3/sp500_constituent 403s (Legacy Endpoint) for keys created
# after 2025-08-31.
_CONSTITUENTS_CSV_URL = (
"https://raw.githubusercontent.com/datasets/"
"s-and-p-500-companies/main/data/constituents.csv"
)
def _fetch_constituents_csv(self) -> Optional[list[dict]]:
"""Fetch the public S&P 500 list, mapped to the v3 response shape.
Uses a bare requests.get, NOT self.session — the FMP apikey header
must not leak to a third-party host.
"""
import csv
import io
try:
response = requests.get(self._CONSTITUENTS_CSV_URL, timeout=30)
if response.status_code != 200:
print(
f"ERROR: constituents CSV fallback failed: {response.status_code}",
file=sys.stderr,
)
return None
data = [
{
# FMP uses dash-style class symbols (BRK-B), the CSV uses dots.
"symbol": row["Symbol"].replace(".", "-"),
"name": row["Security"],
"sector": row["GICS Sector"],
"subSector": row["GICS Sub-Industry"],
}
for row in csv.DictReader(io.StringIO(response.text))
if row.get("Symbol")
]
except (requests.exceptions.RequestException, csv.Error, KeyError) as e:
print(f"ERROR: constituents CSV fallback failed: {e}", file=sys.stderr)
return None
return data or None
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 not data:
# No FMP tier serves this list on some keys (402 on stable, 403
# on v3) — fall back to the public dataset.
data = self._fetch_constituents_csv()
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,
}
scripts/historical_report.py
#!/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,
}
scripts/historical_scanner.py
#!/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
scripts/report_generator.py
#!/usr/bin/env python3
"""
VCP Screener Report Generator
Generates JSON and Markdown reports for VCP screening results.
Outputs:
- JSON: Structured data for programmatic use
- Markdown: Human-readable ranked list with VCP pattern details
"""
import json
from typing import Optional
def generate_json_report(
results: list[dict], metadata: dict, output_file: str, all_results: Optional[list[dict]] = None
):
"""Generate JSON report with screening results.
Args:
results: Top results to include in report detail
metadata: Screening metadata
output_file: Output file path
all_results: Full candidate list for summary stats (defaults to results)
"""
summary_source = all_results if all_results is not None else results
sector_counts = {}
for stock in summary_source:
s = stock.get("sector", "Unknown")
sector_counts[s] = sector_counts.get(s, 0) + 1
report = {
"schema_version": "1.0",
"metadata": metadata,
"results": results,
"summary": _generate_summary(summary_source),
"sector_distribution": sector_counts,
}
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_markdown_report(
results: list[dict], metadata: dict, output_file: str, all_results: Optional[list[dict]] = None
):
"""Generate Markdown report with VCP screening results.
Args:
results: Top results to include in report detail
metadata: Screening metadata
output_file: Output file path
all_results: Full candidate list for summary stats (defaults to results)
"""
lines = []
# Header
lines.append("# VCP Screener Report - Minervini Volatility Contraction Pattern")
lines.append(f"**Generated:** {metadata.get('generated_at', 'N/A')}")
lines.append(f"**Universe:** {metadata.get('universe_description', 'S&P 500')}")
lines.append("")
lines.append("---")
lines.append("")
# Screening funnel
funnel = metadata.get("funnel", {})
lines.append("## Screening Funnel")
lines.append("")
lines.append("| Stage | Count |")
lines.append("|-------|-------|")
lines.append(f"| Universe | {funnel.get('universe', 'N/A')} |")
lines.append(f"| Pre-filter passed | {funnel.get('pre_filter_passed', 'N/A')} |")
lines.append(f"| Trend Template passed | {funnel.get('trend_template_passed', 'N/A')} |")
lines.append(f"| VCP candidates | {funnel.get('vcp_candidates', len(results))} |")
lines.append("")
# Show "top X of Y" when not all results are displayed
total_candidates = len(all_results) if all_results is not None else len(results)
showing_count = len(results)
if showing_count < total_candidates:
lines.append(
f"**Showing top {showing_count} of {total_candidates} candidates** (sorted by composite score)"
)
lines.append("")
lines.append("---")
lines.append("")
# Quick-scan summary table (all displayed candidates at a glance)
if results:
lines.append("## Quick Scan")
lines.append("")
lines.append("| # | Symbol | Quality | State | Type | Price | Pivot Dist |")
lines.append("|---|--------|---------|-------|------|-------|------------|")
for i, stock in enumerate(results, 1):
sym = stock.get("symbol", "?")
q_rating = stock.get("quality_rating", stock.get("rating", "N/A"))
quality = f"{stock.get('composite_score', 0):.0f} ({q_rating})"
state = stock.get("execution_state", "—")
ptype = stock.get("pattern_type", "—")
price = stock.get("price", 0) or 0
dist = stock.get("distance_from_pivot_pct")
dist_str = f"{dist:+.1f}%" if dist is not None else "—"
cap_marker = "★" if stock.get("state_cap_applied") else ""
lines.append(
f"| {i} | {sym} | {quality}{cap_marker} | {state} | {ptype} | ${price:.2f} | {dist_str} |"
)
lines.append("")
lines.append("★ = State Cap applied (rating downgraded from raw score)")
lines.append("")
lines.append("---")
lines.append("")
# Split results into entry_ready sections
section_a = [s for s in results if s.get("entry_ready", False)]
section_b = [s for s in results if not s.get("entry_ready", False)]
# Section A: Pre-Breakout Watchlist
count_label_a = f"{len(section_a)} stock{'s' if len(section_a) != 1 else ''}"
lines.append(f"## Section A: Pre-Breakout Watchlist ({count_label_a})")
lines.append("")
if section_a:
for i, stock in enumerate(section_a, 1):
lines.extend(_format_stock_entry(i, stock))
else:
lines.append("No actionable pre-breakout candidates found.")
lines.append("")
# Section B: Extended / Quality VCP
count_label_b = f"{len(section_b)} stock{'s' if len(section_b) != 1 else ''}"
lines.append(f"## Section B: Extended / Quality VCP ({count_label_b})")
lines.append("")
if section_b:
for i, stock in enumerate(section_b, 1):
lines.extend(_format_stock_entry(i, stock))
else:
lines.append("No extended VCP candidates.")
lines.append("")
# Summary statistics
lines.append("---")
lines.append("")
lines.append("## Summary Statistics")
summary_source = all_results if all_results is not None else results
summary = _generate_summary(summary_source)
lines.append(f"- **Total VCP Candidates:** {summary['total']}")
lines.append(f"- **Textbook VCP:** {summary['textbook']}")
lines.append(f"- **Strong VCP:** {summary['strong']}")
lines.append(f"- **Good VCP:** {summary['good']}")
lines.append(f"- **Developing VCP:** {summary['developing']}")
lines.append(f"- **Weak / No VCP:** {summary['weak']}")
lines.append("")
lines.append("*Counts are based on final rating (after state caps).*")
lines.append("")
# Sector distribution (use all_results for full picture)
sectors = {}
for stock in summary_source:
s = stock.get("sector", "Unknown")
sectors[s] = sectors.get(s, 0) + 1
if sectors:
lines.append("### Sector Distribution")
lines.append("")
lines.append("| Sector | Count |")
lines.append("|--------|-------|")
for sector, count in sorted(sectors.items(), key=lambda x: -x[1]):
lines.append(f"| {sector} | {count} |")
lines.append("")
# API usage
api_stats = metadata.get("api_stats", {})
if api_stats:
lines.append("### API Usage")
lines.append(f"- **API Calls Made:** {api_stats.get('api_calls_made', 'N/A')}")
lines.append(f"- **Cache Entries:** {api_stats.get('cache_entries', 'N/A')}")
lines.append("")
# Methodology
lines.append("---")
lines.append("")
lines.append("## Methodology")
lines.append("")
lines.append("This screener implements Mark Minervini's Volatility Contraction Pattern (VCP):")
lines.append("")
lines.append("1. **Trend Template** (25%) - 7-point Stage 2 uptrend filter")
lines.append(
"2. **Contraction Quality** (25%) - VCP pattern with successive tighter corrections"
)
lines.append("3. **Volume Pattern** (20%) - Volume dry-up near pivot point")
lines.append("4. **Pivot Proximity** (15%) - Distance from breakout level")
lines.append("5. **Relative Strength** (15%) - Minervini-weighted RS vs S&P 500")
lines.append("")
lines.append(
"For detailed methodology, see the VCP methodology reference in the vcp-screener skill directory."
)
lines.append("")
# Disclaimer
lines.append("---")
lines.append("")
lines.append(
"**Disclaimer:** This screener is for educational and informational purposes only. "
"Not investment advice. Always conduct your own research and consult a financial "
"advisor before making investment decisions. Past patterns do not guarantee future results."
)
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 _format_stock_entry(rank: int, stock: dict) -> list[str]:
"""Format a single stock entry for the Markdown report."""
lines = []
# Header — symbol + company name only
lines.append(f"### {rank}. {stock['symbol']} - {stock.get('company_name', 'N/A')}")
# 2-axis quality line: Quality (pre-cap) | State | Type + final rating
quality_rating = stock.get("quality_rating", stock.get("rating", "N/A"))
final_rating = stock.get("rating", "N/A")
composite = stock.get("composite_score", 0)
execution_state = stock.get("execution_state", "—")
pattern_type = stock.get("pattern_type", "—")
cap_note = ""
if stock.get("state_cap_applied"):
cap_note = f" ★ → {final_rating}"
lines.append(
f"**Quality:** {composite:.0f}/100 ({quality_rating}){cap_note} | "
f"**State:** {execution_state} | "
f"**Type:** {pattern_type}"
)
# Basic info
price = stock.get("price", 0) or 0
mcap = stock.get("market_cap", 0) or 0
mcap_str = (
f"${mcap / 1e9:.1f}B" if mcap >= 1e9 else (f"${mcap / 1e6:.0f}M" if mcap > 0 else "N/A")
)
lines.append(
f"**Price:** ${price:.2f} | **Market Cap:** {mcap_str} | "
f"**Sector:** {stock.get('sector', 'N/A')}"
)
lines.append("")
# Component breakdown table
lines.append("| Component | Score | Details |")
lines.append("|-----------|-------|---------|")
# Trend Template
tt = stock.get("trend_template", {})
tt_score = tt.get("score", 0)
tt_pass = f"{tt.get('criteria_passed', 0)}/7 criteria"
ext_penalty = tt.get("extended_penalty", 0)
if ext_penalty < 0:
raw = tt.get("raw_score", tt_score)
tt_pass += f" (raw {raw:.0f}, ext {ext_penalty:+d})"
lines.append(f"| Trend Template | {tt_score:.0f}/100 | {tt_pass} |")
# Contraction Quality
vcp = stock.get("vcp_pattern", {})
vcp_score = vcp.get("score", 0)
num_c = vcp.get("num_contractions", 0)
contractions = vcp.get("contractions", [])
depths = ", ".join([f"{c['label']}={c['depth_pct']:.1f}%" for c in contractions[:4]])
lines.append(f"| Contraction Quality | {vcp_score:.0f}/100 | {num_c} contractions: {depths} |")
# Volume Pattern
vol = stock.get("volume_pattern", {})
vol_score = vol.get("score", 0)
dry_up = vol.get("dry_up_ratio")
dry_up_str = f"Dry-up: {dry_up:.2f}" if dry_up is not None else "N/A"
lines.append(f"| Volume Pattern | {vol_score:.0f}/100 | {dry_up_str} |")
# Pivot Proximity
piv = stock.get("pivot_proximity", {})
piv_score = piv.get("score", 0)
dist = piv.get("distance_from_pivot_pct")
status = piv.get("trade_status", "N/A")
dist_str = f"{dist:+.1f}% from pivot" if dist is not None else "N/A"
lines.append(f"| Pivot Proximity | {piv_score:.0f}/100 | {dist_str} ({status}) |")
# Relative Strength
rs = stock.get("relative_strength", {})
rs_score = rs.get("score", 0)
rs_rank = rs.get("rs_rank_estimate", "N/A")
rs_percentile = rs.get("rs_percentile")
weighted_rs = rs.get("weighted_rs")
if rs_percentile is not None:
rs_str = f"RS Percentile: {rs_percentile}"
else:
rs_str = f"RS Rank ~{rs_rank}"
if weighted_rs is not None:
rs_str += f", Weighted RS: {weighted_rs:+.1f}%"
lines.append(f"| Relative Strength | {rs_score:.0f}/100 | {rs_str} |")
lines.append("")
# Trade setup (distance-aware)
pivot_price = vcp.get("pivot_price")
stop_loss = piv.get("stop_loss_price")
risk_pct = piv.get("risk_pct")
dist = piv.get("distance_from_pivot_pct")
trade_status = piv.get("trade_status", "")
lines.append("**Trade Setup:**")
if trade_status == "BELOW STOP LEVEL":
# Stop violated: setup is invalidated
lines.append(f"- Pivot: ${pivot_price:.2f}" if pivot_price else "- Pivot: N/A")
lines.append(f"- Stop-loss: ${stop_loss:.2f}" if stop_loss else "- Stop-loss: N/A")
lines.append("- **STOP VIOLATED:** Price is below stop-loss level — setup invalidated.")
lines.append("- Action: Do not enter. Wait for a new base to form.")
elif dist is not None and dist > 10:
# Overextended: trade missed
lines.append(
f"- Original pivot: ${pivot_price:.2f} (current price is +{dist:.1f}% above)"
if pivot_price
else "- Pivot: N/A"
)
if risk_pct is not None:
lines.append(
f"- TRADE MISSED: Entry at current level requires {risk_pct:.1f}% "
"stop distance — not a valid setup."
)
else:
lines.append("- TRADE MISSED: Too far above pivot for a valid entry.")
lines.append("- Action: Wait for new base formation and a new pivot point.")
elif dist is not None and 5 < dist <= 10:
# Chase warning zone
lines.append(f"- Pivot: ${pivot_price:.2f}" if pivot_price else "- Pivot: N/A")
lines.append(f"- Stop-loss: ${stop_loss:.2f}" if stop_loss else "- Stop-loss: N/A")
lines.append(
f"- Risk from current price: {risk_pct:.1f}%" if risk_pct is not None else "- Risk: N/A"
)
lines.append(
f"- WARNING: +{dist:.1f}% above pivot — consider waiting for pullback to pivot."
)
else:
# Normal range (-8% to +5%) or below
lines.append(f"- Pivot: ${pivot_price:.2f}" if pivot_price else "- Pivot: N/A")
lines.append(f"- Stop-loss: ${stop_loss:.2f}" if stop_loss else "- Stop-loss: N/A")
lines.append(f"- Risk: {risk_pct:.1f}%" if risk_pct is not None else "- Risk: N/A")
guidance = stock.get("guidance", "N/A")
if trade_status == "BELOW STOP LEVEL":
guidance = "STOP VIOLATED — setup invalidated. Do not enter."
elif "EXTENDED" in trade_status or "OVEREXTENDED" in trade_status:
dist_val = piv.get("distance_from_pivot_pct", 0)
guidance += (
f" | WARNING: Stock is +{dist_val:.1f}% above pivot - "
"Minervini advises against chasing >5% above pivot"
)
lines.append(f"- Guidance: {guidance}")
lines.append("")
lines.append("---")
lines.append("")
return lines
def _rating_indicator(score: float, valid_vcp: bool = True) -> str:
"""Get indicator for rating."""
if not valid_vcp:
return "[PATTERN NOT CONFIRMED]"
if score >= 90:
return "[TEXTBOOK]"
elif score >= 80:
return "[STRONG]"
elif score >= 70:
return "[GOOD]"
elif score >= 60:
return "[DEVELOPING]"
else:
return ""
def _generate_summary(results: list[dict]) -> dict:
"""Generate summary statistics based on rating (not raw composite_score).
Uses the ``rating`` field so that valid_vcp-capped stocks are counted
correctly (e.g. composite=72 but rating='Developing VCP' counts as
developing, not good).
"""
total = len(results)
textbook = sum(1 for s in results if s.get("rating") == "Textbook VCP")
strong = sum(1 for s in results if s.get("rating") == "Strong VCP")
good = sum(1 for s in results if s.get("rating") == "Good VCP")
developing = sum(1 for s in results if s.get("rating") == "Developing VCP")
weak = sum(1 for s in results if s.get("rating") in ("Weak VCP", "No VCP"))
return {
"total": total,
"textbook": textbook,
"strong": strong,
"good": good,
"developing": developing,
"weak": weak,
}
scripts/scorer.py
#!/usr/bin/env python3
"""
VCP Screener - 5-Component Composite Scoring Engine
Combines component scores into a weighted composite (0-100).
Component Weights:
1. Trend Template: 25%
2. Contraction Quality: 25%
3. Volume Pattern: 20%
4. Pivot Proximity: 15%
5. Relative Strength: 15%
Total: 100%
Rating Bands:
90-100: Textbook VCP - Buy at pivot, aggressive sizing
80-89: Strong VCP - Buy at pivot, standard sizing
70-79: Good VCP - Buy on volume confirmation
60-69: Developing - Watchlist, wait for tighter pivot
50-59: Weak VCP - Monitor only
<50: No VCP - Not a VCP setup
State Caps:
Invalid / Damaged → max "No VCP" (setup not actionable)
Overextended → max "Weak VCP" (too far to buy safely)
Extended → max "Developing VCP" (chase risk too high)
Early-post-breakout → max "Strong VCP" (volume unconfirmed)
Wide-and-Loose pattern → max "Developing VCP" (Textbook/Strong/Good禁止)
"""
from typing import Optional
from calculators.execution_state import apply_state_cap
COMPONENT_WEIGHTS = {
"trend_template": 0.25,
"contraction_quality": 0.25,
"volume_pattern": 0.20,
"pivot_proximity": 0.15,
"relative_strength": 0.15,
}
COMPONENT_LABELS = {
"trend_template": "Trend Template (Stage 2)",
"contraction_quality": "Contraction Quality",
"volume_pattern": "Volume Pattern",
"pivot_proximity": "Pivot Proximity",
"relative_strength": "Relative Strength",
}
def calculate_composite_score(
trend_score: float,
contraction_score: float,
volume_score: float,
pivot_score: float,
rs_score: float,
valid_vcp: bool = True,
execution_state: Optional[str] = None,
pattern_type: Optional[str] = None,
wide_and_loose: bool = False,
sma200_extension_pct: Optional[float] = None,
) -> dict:
"""
Calculate weighted composite VCP score with State Caps applied.
Args:
trend_score: Trend Template score (0-100)
contraction_score: VCP contraction quality score (0-100)
volume_score: Volume dry-up pattern score (0-100)
pivot_score: Pivot proximity score (0-100)
rs_score: Relative Strength score (0-100)
valid_vcp: Whether VCP pattern passed validation (contraction ratios)
execution_state: Output of compute_execution_state() — limits max rating
pattern_type: Output of classify_pattern() — stored in result
wide_and_loose: If True, cap rating at Strong VCP (Textbook禁止)
sma200_extension_pct: % above SMA200 — stored for reporting (Phase 2)
Returns:
Dict with composite_score, rating, guidance, component breakdown,
execution_state, pattern_type, state_cap_applied
"""
component_scores = {
"trend_template": trend_score,
"contraction_quality": contraction_score,
"volume_pattern": volume_score,
"pivot_proximity": pivot_score,
"relative_strength": rs_score,
}
# Calculate weighted composite
composite = 0.0
for key, weight in COMPONENT_WEIGHTS.items():
composite += component_scores[key] * weight
composite = round(composite, 1)
# Find weakest and strongest
weakest_key = min(component_scores, key=component_scores.get)
strongest_key = max(component_scores, key=component_scores.get)
# Rating (raw — before any caps)
rating_info = _get_rating(composite)
# Override rating when VCP pattern is not validated (e.g. expanding contractions)
if not valid_vcp and composite >= 70:
rating_info = {
"rating": "Developing VCP",
"description": "VCP pattern not confirmed - contractions do not meet criteria",
"guidance": "Watchlist only - VCP pattern not validated, do not buy",
}
rating = rating_info["rating"]
quality_rating = rating # Pre-cap rating (structure quality only)
cap_applied = False
cap_reason = None
# State Cap: execution state limits maximum allowed rating
if execution_state is not None:
capped_rating, capped = apply_state_cap(rating, execution_state)
if capped:
cap_reason = f"State cap from {execution_state}: {rating} → {capped_rating}"
rating = capped_rating
cap_applied = True
rating_info = _get_rating_info_for(rating)
# Wide-and-loose cap: max Developing VCP
if wide_and_loose and rating in ("Textbook VCP", "Strong VCP", "Good VCP"):
cap_reason = (cap_reason or "") + f" | Wide-and-loose: {rating} → Developing VCP"
rating = "Developing VCP"
cap_applied = True
rating_info = _get_rating_info_for(rating)
return {
"composite_score": composite,
"quality_rating": quality_rating,
"rating": rating,
"rating_description": rating_info["description"],
"guidance": rating_info["guidance"],
"valid_vcp": valid_vcp,
"execution_state": execution_state,
"pattern_type": pattern_type,
"state_cap_applied": cap_applied,
"cap_reason": cap_reason,
"weakest_component": COMPONENT_LABELS[weakest_key],
"weakest_score": component_scores[weakest_key],
"strongest_component": COMPONENT_LABELS[strongest_key],
"strongest_score": component_scores[strongest_key],
"component_breakdown": {
k: {
"score": component_scores[k],
"weight": w,
"weighted": round(component_scores[k] * w, 1),
"label": COMPONENT_LABELS[k],
}
for k, w in COMPONENT_WEIGHTS.items()
},
}
def _get_rating_info_for(rating: str) -> dict:
"""Return rating info dict for a known rating string (used after cap overrides)."""
table = {
"Textbook VCP": {
"rating": "Textbook VCP",
"description": "Ideal VCP setup with all components aligned",
"guidance": "Buy at pivot, aggressive position sizing (1.5-2x normal)",
},
"Strong VCP": {
"rating": "Strong VCP",
"description": "High-quality VCP with minor imperfections",
"guidance": "Buy at pivot, standard position sizing",
},
"Good VCP": {
"rating": "Good VCP",
"description": "Solid VCP pattern developing",
"guidance": "Buy on volume confirmation above pivot",
},
"Developing VCP": {
"rating": "Developing VCP",
"description": "VCP forming but not yet actionable",
"guidance": "Watchlist - wait for tighter contraction near pivot",
},
"Weak VCP": {
"rating": "Weak VCP",
"description": "Some VCP characteristics but incomplete",
"guidance": "Monitor only - pattern needs more development",
},
"No VCP": {
"rating": "No VCP",
"description": "Does not qualify as a VCP setup",
"guidance": "Not actionable as VCP",
},
}
return table.get(
rating,
{"rating": rating, "description": "", "guidance": ""},
)
def _get_rating(composite: float) -> dict:
"""Map composite score to rating and guidance."""
if composite >= 90:
return {
"rating": "Textbook VCP",
"description": "Ideal VCP setup with all components aligned",
"guidance": "Buy at pivot, aggressive position sizing (1.5-2x normal)",
}
elif composite >= 80:
return {
"rating": "Strong VCP",
"description": "High-quality VCP with minor imperfections",
"guidance": "Buy at pivot, standard position sizing",
}
elif composite >= 70:
return {
"rating": "Good VCP",
"description": "Solid VCP pattern developing",
"guidance": "Buy on volume confirmation above pivot",
}
elif composite >= 60:
return {
"rating": "Developing VCP",
"description": "VCP forming but not yet actionable",
"guidance": "Watchlist - wait for tighter contraction near pivot",
}
elif composite >= 50:
return {
"rating": "Weak VCP",
"description": "Some VCP characteristics but incomplete",
"guidance": "Monitor only - pattern needs more development",
}
else:
return {
"rating": "No VCP",
"description": "Does not qualify as a VCP setup",
"guidance": "Not actionable as VCP",
}
scripts/screen_vcp.py
#!/usr/bin/env python3
"""
VCP Stock Screener - Main Orchestrator
Screens S&P 500 stocks for Mark Minervini's Volatility Contraction Pattern (VCP).
Uses a 3-phase pipeline: Pre-filter -> Trend Template -> VCP Detection & Scoring.
Usage:
# Default (S&P 500, top 100 candidates, free tier API)
python3 screen_vcp.py --api-key YOUR_KEY
# Custom universe
python3 screen_vcp.py --universe AAPL NVDA MSFT AMZN META
# Full S&P 500 (requires paid API tier)
python3 screen_vcp.py --full-sp500
Output:
- JSON: vcp_screener_YYYY-MM-DD_HHMMSS.json
- Markdown: vcp_screener_YYYY-MM-DD_HHMMSS.md
"""
import argparse
import os
import sys
from datetime import datetime
from typing import Optional
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(__file__))
from calculators.execution_state import compute_execution_state
from calculators.pattern_classifier import classify_pattern
from calculators.pivot_proximity_calculator import calculate_pivot_proximity
from calculators.relative_strength_calculator import (
calculate_relative_strength,
rank_relative_strength_universe,
)
from calculators.trend_template_calculator import calculate_trend_template
from calculators.vcp_pattern_calculator import calculate_vcp_pattern
from calculators.volume_pattern_calculator import calculate_volume_pattern
from fmp_client import FMPClient
from report_generator import generate_json_report, generate_markdown_report
from scorer import calculate_composite_score
# Historical scan window default (~5 years in trading days). Used by
# argparse `const=` so bare `--history` keeps the prior default behavior.
DEFAULT_HISTORY_DAYS = 1260
def parse_arguments():
parser = argparse.ArgumentParser(
description="VCP Stock Screener - Minervini Volatility Contraction Pattern"
)
parser.add_argument(
"--api-key", help="FMP API key (defaults to FMP_API_KEY environment variable)"
)
parser.add_argument(
"--max-candidates",
type=int,
default=100,
help="Max stocks for full VCP analysis after pre-filter (default: 100)",
)
parser.add_argument(
"--top", type=int, default=20, help="Top results to include in report (default: 20)"
)
parser.add_argument("--output-dir", default="reports/", help="Output directory for reports")
parser.add_argument(
"--universe", nargs="+", help="Custom symbols to screen (overrides S&P 500)"
)
parser.add_argument(
"--full-sp500",
action="store_true",
help="Screen all S&P 500 stocks (requires paid API tier, ~350 calls)",
)
parser.add_argument(
"--mode",
choices=["all", "prebreakout"],
default="all",
help="Output mode: 'all' shows everything, 'prebreakout' shows entry_ready only (default: all)",
)
parser.add_argument(
"--max-above-pivot",
type=float,
default=3.0,
help="Max %% above pivot for entry_ready (default: 3.0)",
)
parser.add_argument(
"--max-risk", type=float, default=15.0, help="Max risk %% for entry_ready (default: 15.0)"
)
parser.add_argument(
"--no-require-valid-vcp",
action="store_true",
help="Do not require valid_vcp=True for entry_ready",
)
parser.add_argument(
"--min-atr-pct",
type=float,
default=1.0,
help="Min avg daily range %% to exclude stale/acquired stocks (default: 1.0)",
)
parser.add_argument(
"--ext-threshold",
type=float,
default=8.0,
help="SMA50 distance %% where extended penalty starts (default: 8.0)",
)
parser.add_argument(
"--min-contractions",
type=int,
default=2,
help="Minimum contractions for valid VCP (default: 2)",
)
parser.add_argument(
"--t1-depth-min",
type=float,
default=10.0,
help="Minimum T1 depth %% (default: 10.0)",
)
parser.add_argument(
"--breakout-volume-ratio",
type=float,
default=1.5,
help="Breakout volume ratio vs 50d avg (default: 1.5)",
)
parser.add_argument(
"--trend-min-score",
type=float,
default=85.0,
help="Minimum trend template raw score for Phase 2 pass (default: 85.0)",
)
parser.add_argument(
"--atr-multiplier",
type=float,
default=1.5,
help="ATR multiplier for ZigZag swing detection (default: 1.5)",
)
parser.add_argument(
"--contraction-ratio",
type=float,
default=0.70,
help="Max ratio for successive contractions (default: 0.70)",
)
parser.add_argument(
"--min-contraction-days",
type=int,
default=5,
help="Minimum days per contraction (default: 5)",
)
parser.add_argument(
"--lookback-days",
type=int,
default=120,
help="VCP pattern lookback window in days (default: 120)",
)
parser.add_argument(
"--max-sma200-extension",
type=float,
default=50.0,
help="Max %% above SMA200 before Overextended state (default: 50.0)",
)
parser.add_argument(
"--wide-and-loose-threshold",
type=float,
default=15.0,
help="Final contraction depth %% above which (with <10d duration) flags wide-and-loose (default: 15.0)",
)
parser.add_argument(
"--strict",
action="store_true",
help=(
"Minervini strict mode: only include stocks with valid_vcp=True AND "
"execution_state in ('Pre-breakout', 'Breakout')"
),
)
hist_group = parser.add_argument_group("Historical single-ticker mode")
# --history accepts an optional integer: scan-window length in trading days.
# Bare --history uses DEFAULT_HISTORY_DAYS (5 years). The total fetch is
# this value plus lookback, outcome window, and a safety buffer (computed
# internally in run_historical).
hist_group.add_argument(
"--history",
nargs="?",
type=int,
const=DEFAULT_HISTORY_DAYS,
default=None,
metavar="DAYS",
help=(
f"Scan a single ticker's history for all VCPs. With a value, scans "
f"NNN trading days back from the present; without a value, defaults "
f"to {DEFAULT_HISTORY_DAYS} trading days (~5 years). Requires --ticker."
),
)
hist_group.add_argument(
"--ticker",
help="Ticker symbol for historical scan (e.g. FIX, TSLA)",
)
hist_group.add_argument(
"--stride-days",
type=int,
default=5,
help="Trading-day step for the as-of cursor (default 5)",
)
hist_group.add_argument(
"--outcome-days",
type=int,
default=60,
help="Forward window for outcome evaluation (default 60)",
)
args = parser.parse_args()
# Lower bound is 2 by design: VCP requires successive contractions, and
# contraction_ratio scoring needs at least 2 to compute ratios.
if not (2 <= args.min_contractions <= 4):
parser.error("--min-contractions must be 2-4")
if not (1.0 <= args.t1_depth_min <= 50.0):
parser.error("--t1-depth-min must be 1.0-50.0")
if not (0.5 <= args.breakout_volume_ratio <= 10.0):
parser.error("--breakout-volume-ratio must be 0.5-10.0")
if not (0 <= args.trend_min_score <= 100):
parser.error("--trend-min-score must be 0-100")
if not (0.5 <= args.atr_multiplier <= 5.0):
parser.error("--atr-multiplier must be 0.5-5.0")
if not (0.1 <= args.contraction_ratio <= 1.0):
parser.error("--contraction-ratio must be 0.1-1.0")
if not (1 <= args.min_contraction_days <= 30):
parser.error("--min-contraction-days must be 1-30")
if not (30 <= args.lookback_days <= 365):
parser.error("--lookback-days must be 30-365")
if args.history is not None:
if not args.ticker:
parser.error("--history requires --ticker SYM")
if not (100 <= args.history <= 5040):
parser.error(
"--history must be 100-5040 trading days (approximately 5 months to 20 years)"
)
if not (1 <= args.stride_days <= 60):
parser.error("--stride-days must be 1-60")
if not (5 <= args.outcome_days <= 252):
parser.error("--outcome-days must be 5-252")
return args
def passes_trend_filter(tt_result: dict, trend_min_score: float = 85.0) -> bool:
"""Check if a stock passes Phase 2 trend template filter.
Uses raw_score (before extended penalty) so that the CLI --trend-min-score
flag can override the calculator's hardcoded 85-point gate.
"""
return tt_result.get("raw_score", 0) >= trend_min_score
def pre_filter_stock(quote: dict) -> tuple:
"""
Cheap pre-filter using quote data only.
Criteria:
- Price > $10
- At least 20% above 52-week low
- Within 30% of 52-week high
- Average volume > 200,000
Returns:
(passed: bool, stage2_likelihood_score: float)
"""
price = quote.get("price", 0)
year_high = quote.get("yearHigh", 0)
year_low = quote.get("yearLow", 0)
# FMP's /stable quote dropped the v3-only `avgVolume` field, so fall back to
# the session `volume` as a liquidity floor; without this the < 200k check
# rejects 100% of the universe. (v3 `avgVolume` still wins when present.)
avg_volume = quote.get("avgVolume") or quote.get("volume", 0)
if price <= 10:
return False, 0
if avg_volume < 200000:
return False, 0
# Check distance from 52w low
if year_low <= 0:
return False, 0
pct_above_low = (price - year_low) / year_low
if pct_above_low < 0.20:
return False, 0
# Check distance from 52w high
if year_high <= 0:
return False, 0
pct_below_high = (year_high - price) / year_high
if pct_below_high > 0.30:
return False, 0
# Stage 2 likelihood score (higher = more likely in uptrend)
# Combines proximity to high and distance from low
# Cap pct_above_low at 1.0 to normalize score to 0-100 range
capped_above_low = min(pct_above_low, 1.0)
score = capped_above_low * 50 + (1 - pct_below_high) * 50
return True, score
def analyze_stock(
symbol: str,
historical: list[dict],
quote: dict,
sp500_history: list[dict],
sector: str = "Unknown",
company_name: str = "",
ext_threshold: float = 8.0,
min_contractions: int = 2,
t1_depth_min: float = 10.0,
contraction_ratio: float = 0.70,
atr_multiplier: float = 1.5,
min_contraction_days: int = 5,
lookback_days: int = 120,
breakout_volume_ratio: float = 1.5,
max_sma200_extension: float = 50.0,
wide_and_loose_threshold: float = 15.0,
as_of_offset: int = 0,
) -> Optional[dict]:
"""
Full VCP analysis for a single stock (Phase 3).
No additional API calls needed - uses pre-fetched data.
Args:
as_of_offset: Index into ``historical`` (most-recent-first) that the
analysis should treat as "today". Default 0 = analyze right edge,
matching cross-sectional screening. Positive values shift the
as-of cursor into the past for historical/walk-forward analysis;
the caller is responsible for synthesizing ``quote`` (price /
yearHigh / yearLow) from the OHLCV slice at that offset.
``sp500_history`` is sliced identically so RS comparisons stay
aligned. Bars more recent than ``historical[as_of_offset]`` are
ignored.
"""
# Historical mode: shift the as-of cursor by slicing both arrays so
# historical[as_of_offset] becomes index 0 for every downstream calculator.
if as_of_offset > 0:
historical = historical[as_of_offset:]
if sp500_history:
sp500_history = sp500_history[as_of_offset:]
price = quote.get("price", 0)
market_cap = quote.get("marketCap", 0)
# 1. Relative Strength (needed for Trend Template criterion 7)
rs_result = calculate_relative_strength(historical, sp500_history)
rs_rank = rs_result.get("rs_rank_estimate", 0)
# 2. Trend Template
tt_result = calculate_trend_template(
historical,
quote,
rs_rank=rs_rank,
ext_threshold=ext_threshold,
max_sma200_extension=max_sma200_extension,
)
# 3. VCP Pattern Detection
vcp_result = calculate_vcp_pattern(
historical,
lookback_days=lookback_days,
atr_multiplier=atr_multiplier,
min_contraction_days=min_contraction_days,
min_contractions=min_contractions,
t1_depth_min=t1_depth_min,
contraction_ratio=contraction_ratio,
wide_and_loose_threshold=wide_and_loose_threshold,
)
# 4. Volume Pattern
# Slice to the same lookback window the VCP calculator used. Contraction
# high_idx/low_idx are chronological indices over historical[:lookback_days];
# _zone_volume_analysis maps them back via n - 1 - chrono_idx, so n must
# equal that slice length or the reverse mapping reads the wrong bars.
pivot_price = vcp_result.get("pivot_price")
historical_lookback = historical[:lookback_days]
vol_result = calculate_volume_pattern(
historical_lookback,
pivot_price=pivot_price,
contractions=vcp_result.get("contractions"),
breakout_volume_ratio=breakout_volume_ratio,
)
# 5. Pivot Proximity
last_low = None
contractions = vcp_result.get("contractions", [])
if contractions:
last_low = contractions[-1].get("low_price")
piv_result = calculate_pivot_proximity(
current_price=price,
pivot_price=pivot_price,
last_contraction_low=last_low,
breakout_volume=vol_result.get("breakout_volume_detected", False),
)
# 6. Execution State — separates "strong pattern" from "buyable now"
sma200_tt = tt_result.get("sma200")
sma200_distance_pct: Optional[float] = None
if sma200_tt and sma200_tt > 0:
sma200_distance_pct = (price - sma200_tt) / sma200_tt * 100
exec_state_result = compute_execution_state(
distance_from_pivot_pct=piv_result.get("distance_from_pivot_pct"),
price=price,
sma50=tt_result.get("sma50"),
sma200=sma200_tt,
sma200_distance_pct=sma200_distance_pct,
last_contraction_low=last_low,
breakout_volume=vol_result.get("breakout_volume_detected", False),
max_sma200_extension=max_sma200_extension,
)
execution_state = exec_state_result["state"]
# 7. Pattern Classifier
valid_vcp = vcp_result.get("valid_vcp", False)
wide_and_loose = vcp_result.get("wide_and_loose", False)
final_depth = contractions[-1].get("depth_pct") if contractions else None
pattern_type = classify_pattern(
valid_vcp=valid_vcp,
num_contractions=vcp_result.get("num_contractions", 0),
final_contraction_depth=final_depth,
execution_state=execution_state,
dry_up_ratio=vol_result.get("dry_up_ratio"),
wide_and_loose=wide_and_loose,
)
# 8. Composite Score (with State Caps)
composite = calculate_composite_score(
trend_score=tt_result.get("score", 0),
contraction_score=vcp_result.get("score", 0),
volume_score=vol_result.get("score", 0),
pivot_score=piv_result.get("score", 0),
rs_score=rs_result.get("score", 0),
valid_vcp=valid_vcp,
execution_state=execution_state,
pattern_type=pattern_type,
wide_and_loose=wide_and_loose,
sma200_extension_pct=sma200_distance_pct,
)
return {
"symbol": symbol,
"company_name": company_name,
"sector": sector,
"price": price,
"market_cap": market_cap,
"composite_score": composite["composite_score"],
"quality_rating": composite.get("quality_rating", composite["rating"]),
"rating": composite["rating"],
"rating_description": composite["rating_description"],
"guidance": composite["guidance"],
"valid_vcp": valid_vcp,
"execution_state": execution_state,
"execution_state_reasons": exec_state_result.get("reasons", []),
"pattern_type": pattern_type,
"wide_and_loose": wide_and_loose,
"state_cap_applied": composite.get("state_cap_applied", False),
"cap_reason": composite.get("cap_reason"),
"sma200_distance_pct": round(sma200_distance_pct, 1)
if sma200_distance_pct is not None
else None,
"distance_from_pivot_pct": piv_result.get("distance_from_pivot_pct"),
"weakest_component": composite["weakest_component"],
"weakest_score": composite["weakest_score"],
"strongest_component": composite["strongest_component"],
"strongest_score": composite["strongest_score"],
"trend_template": tt_result,
"vcp_pattern": vcp_result,
"volume_pattern": vol_result,
"pivot_proximity": piv_result,
"relative_strength": rs_result,
}
def is_stale_price(
historical: list[dict],
lookback: int = 10,
threshold: float = 1.0,
) -> bool:
"""Detect acquired/pinned stocks with abnormally flat price action.
Args:
historical: Price data (most-recent-first)
lookback: Number of recent days to check
threshold: Max average daily range % to be considered stale
Returns:
True if the stock's price action is stale (likely acquired/pinned)
"""
if len(historical) < lookback:
return False
recent = historical[:lookback]
ranges = []
for bar in recent:
high = bar.get("high", 0)
low = bar.get("low", 0)
close = bar.get("close", 0)
if close > 0:
ranges.append((high - low) / close * 100)
if not ranges:
return False
avg_range_pct = sum(ranges) / len(ranges)
return avg_range_pct < threshold
def compute_entry_ready(
result: dict,
max_above_pivot: float = 3.0,
max_risk: float = 15.0,
require_valid_vcp: bool = True,
) -> bool:
"""Determine if a stock is entry-ready based on configurable thresholds.
Args:
result: Analysis result dict from analyze_stock()
max_above_pivot: Max % above pivot for entry readiness
max_risk: Max risk % for entry readiness
require_valid_vcp: Whether valid_vcp=True is required
"""
# State-based immediate rejection
state = result.get("execution_state")
if state in ("Invalid", "Damaged", "Overextended", "Extended", "Early-post-breakout"):
return False
valid_vcp = result.get("valid_vcp", False)
distance = result.get("distance_from_pivot_pct")
dry_up_ratio = result.get("volume_pattern", {}).get("dry_up_ratio")
risk_pct = result.get("pivot_proximity", {}).get("risk_pct")
if require_valid_vcp and not valid_vcp:
return False
if distance is None:
return False
if not (-8.0 <= distance <= max_above_pivot):
return False
if dry_up_ratio is None or dry_up_ratio > 1.0:
return False
# Reject if price is below stop level (risk_pct == 0 means price < stop)
trade_status = result.get("pivot_proximity", {}).get("trade_status")
if trade_status == "BELOW STOP LEVEL":
return False
if risk_pct is None or risk_pct <= 0 or risk_pct > max_risk:
return False
return True
def run_historical(args, client) -> None:
"""Historical single-ticker scan: fetch long history, walk the as-of
cursor, attach forward outcomes, write reports. Exits via return."""
from historical_report import (
generate_historical_json_report,
generate_historical_markdown_report,
)
from historical_scanner import sanitize_ticker, scan_history
try:
ticker = sanitize_ticker(args.ticker)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# Required bars = scan window (args.history) + lookback at the oldest
# offset + outcome window. A 60-bar buffer protects against FMP returning
# fewer bars than requested due to holidays/halts/recent listings.
scan_days = args.history
required_days = scan_days + args.lookback_days + args.outcome_days
fetch_days = required_days + 60
print()
print(f"Historical VCP Scan — {ticker}")
print("-" * 70)
print(
f" Fetching {fetch_days}-day history for {ticker} and SPY...",
end=" ",
flush=True,
)
ticker_data = client.get_historical_prices(ticker, days=fetch_days)
spy_data = client.get_historical_prices("SPY", days=fetch_days)
historical = ticker_data.get("historical", []) if ticker_data else []
sp500_history = spy_data.get("historical", []) if spy_data else []
if not historical:
print("FAILED")
print(f"ERROR: No historical data returned for {ticker}", file=sys.stderr)
sys.exit(1)
print(f"OK ({len(historical)} ticker bars, {len(sp500_history)} SPY bars)")
if len(historical) < required_days:
print(
f" WARN: requested ~{required_days} bars but FMP returned "
f"{len(historical)}; oldest part of the scan window will be truncated."
)
print(
f" Sweeping history (stride={args.stride_days}d, "
f"lookback={args.lookback_days}d, outcome={args.outcome_days}d)..."
)
analyzer_kwargs = {
"ext_threshold": args.ext_threshold,
"min_contractions": args.min_contractions,
"t1_depth_min": args.t1_depth_min,
"contraction_ratio": args.contraction_ratio,
"atr_multiplier": args.atr_multiplier,
"min_contraction_days": args.min_contraction_days,
"breakout_volume_ratio": args.breakout_volume_ratio,
"max_sma200_extension": args.max_sma200_extension,
"wide_and_loose_threshold": args.wide_and_loose_threshold,
}
detections = scan_history(
ticker,
historical,
sp500_history,
stride_days=args.stride_days,
outcome_days=args.outcome_days,
lookback_days=args.lookback_days,
analyzer_kwargs=analyzer_kwargs,
)
print(f" Found {len(detections)} unique VCP detections")
print()
os.makedirs(args.output_dir, exist_ok=True)
# Use the same YYYY-MM-DD_HHMMSS pattern as the cross-sectional report so
# repeated same-day runs don't overwrite previous output.
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_file = os.path.join(args.output_dir, f"vcp_history_{ticker}_{timestamp}.json")
md_file = os.path.join(args.output_dir, f"vcp_history_{ticker}_{timestamp}.md")
history_range = ""
if historical:
history_range = f"{historical[-1].get('date', '?')} to {historical[0].get('date', '?')}"
metadata = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"symbol": ticker,
"scan_days": scan_days,
"stride_days": args.stride_days,
"lookback_days": args.lookback_days,
"outcome_days": args.outcome_days,
"bars_fetched": len(historical),
"history_range": history_range,
"tuning_params": analyzer_kwargs,
"api_stats": client.get_api_stats(),
}
generate_historical_json_report(ticker, detections, metadata, json_file)
generate_historical_markdown_report(ticker, detections, metadata, md_file)
print()
print("=" * 70)
print(f"Historical VCP scan complete — {ticker}")
print("=" * 70)
print(f" Detections: {len(detections)}")
print(f" JSON Report: {json_file}")
print(f" Markdown Report: {md_file}")
print()
def main():
args = parse_arguments()
if not (0 < args.ext_threshold < 50):
print("ERROR: --ext-threshold must be between 0 and 50 (exclusive)", file=sys.stderr)
sys.exit(1)
print("=" * 70)
print("VCP Stock Screener")
print("Mark Minervini's Volatility Contraction Pattern")
print("=" * 70)
print()
# Initialize FMP client
try:
client = FMPClient(api_key=args.api_key)
print("FMP API client initialized")
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# ------------------------------------------------------------------------
# Historical single-ticker mode dispatch — completes via early return.
# ------------------------------------------------------------------------
if args.history is not None:
run_historical(args, client)
return
# ========================================================================
# Phase 1: Pre-Filter (API-efficient)
# ========================================================================
print()
print("Phase 1: Pre-Filter")
print("-" * 70)
# Determine universe
if args.universe:
symbols = args.universe
universe_desc = f"Custom ({len(symbols)} stocks)"
print(f" Using custom universe: {len(symbols)} stocks")
else:
print(" Fetching S&P 500 constituents...", end=" ", flush=True)
constituents = client.get_sp500_constituents()
if not constituents:
print("FAILED")
print("ERROR: Unable to fetch S&P 500 constituents", file=sys.stderr)
sys.exit(1)
symbols = [c["symbol"] for c in constituents]
universe_desc = f"S&P 500 ({len(symbols)} stocks)"
print(f"OK ({len(symbols)} stocks)")
# Build sector/name lookup
sector_map = {}
name_map = {}
if not args.universe and constituents:
for c in constituents:
sector_map[c["symbol"]] = c.get("sector", "Unknown")
name_map[c["symbol"]] = c.get("name", c["symbol"])
# Batch fetch quotes
print(" Fetching quotes...", end=" ", flush=True)
all_quotes = client.get_batch_quotes(symbols)
print(f"OK ({len(all_quotes)} quotes)")
# Apply pre-filter
print(" Applying pre-filter...", end=" ", flush=True)
pre_filtered = []
for sym in symbols:
quote = all_quotes.get(sym)
if not quote:
continue
passed, likelihood = pre_filter_stock(quote)
if passed:
pre_filtered.append((sym, likelihood, quote))
# Sort by Stage 2 likelihood, take top candidates
pre_filtered.sort(key=lambda x: x[1], reverse=True)
max_candidates = len(pre_filtered) if args.full_sp500 else args.max_candidates
candidates = pre_filtered[:max_candidates]
print(f"{len(pre_filtered)} passed, taking top {len(candidates)}")
print()
# ========================================================================
# Phase 2: Trend Template Filter
# ========================================================================
print("Phase 2: Trend Template Filter")
print("-" * 70)
# Fetch SPY historical for RS calculation
print(" Fetching SPY 260-day history...", end=" ", flush=True)
spy_data = client.get_historical_prices("SPY", days=260)
sp500_history = spy_data.get("historical", []) if spy_data else []
if sp500_history:
print(f"OK ({len(sp500_history)} days)")
else:
print("WARN - SPY data unavailable, RS calculations will be limited")
# Fetch historical data for candidates
candidate_symbols = [c[0] for c in candidates]
print(f" Fetching 260-day histories for {len(candidate_symbols)} candidates...")
candidate_histories = {}
for i, sym in enumerate(candidate_symbols):
if (i + 1) % 20 == 0 or i == len(candidate_symbols) - 1:
print(f" Progress: {i + 1}/{len(candidate_symbols)}", flush=True)
data = client.get_historical_prices(sym, days=260)
if data and "historical" in data:
candidate_histories[sym] = data["historical"]
# Apply Trend Template filter
print(" Applying 7-point Trend Template...", end=" ", flush=True)
trend_passed = []
for sym, likelihood, quote in candidates:
hist = candidate_histories.get(sym, [])
if not hist or len(hist) < 50:
continue
# Quick RS calculation for criterion 7
rs_result = calculate_relative_strength(hist, sp500_history)
rs_rank = rs_result.get("rs_rank_estimate", 0)
tt_result = calculate_trend_template(
hist, quote, rs_rank=rs_rank, ext_threshold=args.ext_threshold
)
if passes_trend_filter(tt_result, args.trend_min_score):
trend_passed.append((sym, quote))
print(f"{len(trend_passed)} passed")
print()
# ========================================================================
# Phase 3: VCP Detection & Scoring
# ========================================================================
print("Phase 3: VCP Detection & Scoring")
print("-" * 70)
results = []
for sym, quote in trend_passed:
hist = candidate_histories.get(sym, [])
sector = sector_map.get(sym, "Unknown")
name = name_map.get(sym, sym)
# For custom universe, try to get name from quote
if not name or name == sym:
name = quote.get("name", sym)
if not sector or sector == "Unknown":
sector = quote.get("sector", "Unknown")
# Skip stale/acquired stocks
if is_stale_price(hist, threshold=args.min_atr_pct):
print(f" Skipping {sym} (stale price - likely acquired/pinned)")
continue
print(f" Analyzing {sym}...", end=" ", flush=True)
analysis = analyze_stock(
sym,
hist,
quote,
sp500_history,
sector,
name,
ext_threshold=args.ext_threshold,
min_contractions=args.min_contractions,
t1_depth_min=args.t1_depth_min,
contraction_ratio=args.contraction_ratio,
atr_multiplier=args.atr_multiplier,
min_contraction_days=args.min_contraction_days,
lookback_days=args.lookback_days,
breakout_volume_ratio=args.breakout_volume_ratio,
max_sma200_extension=args.max_sma200_extension,
wide_and_loose_threshold=args.wide_and_loose_threshold,
)
if analysis:
score = analysis["composite_score"]
print(f"Score: {score:.1f} ({analysis['rating']})")
results.append(analysis)
else:
print("FAILED")
print()
# Re-rank RS across the universe for percentile-based scoring
if results:
rs_map = {r["symbol"]: r["relative_strength"] for r in results}
ranked_rs = rank_relative_strength_universe(rs_map)
for r in results:
r["relative_strength"] = ranked_rs[r["symbol"]]
# Recalculate composite score with updated RS (preserving state caps)
composite = calculate_composite_score(
trend_score=r["trend_template"].get("score", 0),
contraction_score=r["vcp_pattern"].get("score", 0),
volume_score=r["volume_pattern"].get("score", 0),
pivot_score=r["pivot_proximity"].get("score", 0),
rs_score=r["relative_strength"].get("score", 0),
valid_vcp=r.get("valid_vcp", False),
execution_state=r.get("execution_state"),
pattern_type=r.get("pattern_type"),
wide_and_loose=r.get("wide_and_loose", False),
sma200_extension_pct=r.get("sma200_extension_pct"),
)
r["composite_score"] = composite["composite_score"]
r["quality_rating"] = composite.get("quality_rating", composite["rating"])
r["rating"] = composite["rating"]
r["rating_description"] = composite["rating_description"]
r["guidance"] = composite["guidance"]
r["weakest_component"] = composite["weakest_component"]
r["weakest_score"] = composite["weakest_score"]
r["strongest_component"] = composite["strongest_component"]
r["strongest_score"] = composite["strongest_score"]
r["state_cap_applied"] = composite.get("state_cap_applied", False)
r["cap_reason"] = composite.get("cap_reason")
# Compute entry_ready using CLI thresholds
require_vcp = not args.no_require_valid_vcp
for r in results:
r["entry_ready"] = compute_entry_ready(
r,
max_above_pivot=args.max_above_pivot,
max_risk=args.max_risk,
require_valid_vcp=require_vcp,
)
# Sort by composite score
results.sort(key=lambda x: x["composite_score"], reverse=True)
# Apply prebreakout filter if requested
if args.mode == "prebreakout":
total_before = len(results)
results = [r for r in results if r.get("entry_ready", False)]
print(f" Pre-breakout filter: {total_before} -> {len(results)} candidates")
print()
# Apply strict mode filter if requested
if args.strict:
total_before = len(results)
results = [
r
for r in results
if r.get("valid_vcp", False)
and r.get("execution_state") in ("Pre-breakout", "Breakout")
]
print(f" Strict mode filter: {total_before} -> {len(results)} candidates")
print()
# ========================================================================
# Generate Reports
# ========================================================================
print("Generating Reports")
print("-" * 70)
os.makedirs(args.output_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_file = os.path.join(args.output_dir, f"vcp_screener_{timestamp}.json")
md_file = os.path.join(args.output_dir, f"vcp_screener_{timestamp}.md")
api_stats = client.get_api_stats()
metadata = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"universe_description": universe_desc,
"max_candidates": max_candidates,
"ext_threshold": args.ext_threshold,
"tuning_params": {
"min_contractions": args.min_contractions,
"t1_depth_min": args.t1_depth_min,
"breakout_volume_ratio": args.breakout_volume_ratio,
"trend_min_score": args.trend_min_score,
"atr_multiplier": args.atr_multiplier,
"contraction_ratio": args.contraction_ratio,
"min_contraction_days": args.min_contraction_days,
"lookback_days": args.lookback_days,
"max_sma200_extension": args.max_sma200_extension,
"wide_and_loose_threshold": args.wide_and_loose_threshold,
"strict": args.strict,
},
"funnel": {
"universe": len(symbols),
"pre_filter_passed": len(pre_filtered),
"trend_template_passed": len(trend_passed),
"vcp_candidates": len(results),
},
"api_stats": api_stats,
}
top_results = results[: args.top]
generate_json_report(top_results, metadata, json_file, all_results=results)
generate_markdown_report(top_results, metadata, md_file, all_results=results)
# ========================================================================
# Summary
# ========================================================================
print()
print("=" * 70)
print("VCP Screening Complete")
print("=" * 70)
# Top 5 display
if results:
print()
print(f"Top {min(5, len(results))} Results:")
for i, s in enumerate(results[:5], 1):
pivot = s.get("vcp_pattern", {}).get("pivot_price")
pivot_str = f"Pivot: ${pivot:.2f}" if pivot else ""
print(
f" {i}. {s['symbol']:6} Score: {s['composite_score']:5.1f} "
f"({s['rating']}) {pivot_str}"
)
else:
print()
print(" No VCP candidates found in this screening run.")
print()
print(f" JSON Report: {json_file}")
print(f" Markdown Report: {md_file}")
print()
print("API Usage:")
print(f" API calls made: {api_stats['api_calls_made']}")
print(f" Cache entries: {api_stats['cache_entries']}")
print()
if __name__ == "__main__":
main()
scripts/tests/conftest.py
"""Shared fixtures for VCP Screener tests"""
import os
import sys
# Add scripts directory to path so modules can be imported
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Add tests directory to path so helpers can be imported
sys.path.insert(0, os.path.dirname(__file__))
scripts/tests/test_fmp_client_historical.py
"""Issue #64: stable/historical-price-eod/full normalization for vcp-screener."""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from fmp_client import FMPClient
def _make_client():
client = FMPClient(api_key="test_key")
client.max_retries = 0
return client
def _mock_response(status_code, json_payload, text=""):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_payload
resp.text = text
return resp
class TestEODFlatListSuccess:
@patch("fmp_client.requests.Session")
def test_get_historical_prices_normalizes_flat_list(self, mock_session_class):
"""Flat list response -> dict contract preserved."""
mock_session = MagicMock()
mock_session.get.return_value = _mock_response(
200,
[
{
"symbol": "SPY",
"date": "2026-04-29",
"open": 500.0,
"high": 502.0,
"low": 499.0,
"close": 501.0,
"volume": 1_000_000,
},
{
"symbol": "SPY",
"date": "2026-04-28",
"open": 498.0,
"high": 501.0,
"low": 497.0,
"close": 500.0,
"volume": 1_100_000,
},
],
)
mock_session_class.return_value = mock_session
client = _make_client()
client.session = mock_session
result = client.get_historical_prices("SPY", days=2)
assert isinstance(result, dict), f"expected dict, got {type(result).__name__}"
assert result["symbol"] == "SPY"
assert len(result["historical"]) == 2
assert result["historical"][0]["close"] == 501.0
first_call = mock_session.get.call_args_list[0]
url = first_call[0][0]
params = first_call[1]["params"]
assert "historical-price-eod/full" in url
assert "from" in params and "to" in params
assert "timeseries" not in params
class TestFallbackTransparency:
"""When stable fails and the client falls back to v3, the user must
see WHY the first endpoint was skipped — otherwise they get the v3
'Legacy Endpoint' error with no context."""
@patch("fmp_client.requests.Session")
def test_stable_error_surfaced_when_falling_back_to_v3(self, mock_session_class, capsys):
"""403 on stable, 403 on v3 — both errors must appear in stderr."""
mock_session = MagicMock()
mock_session.get.side_effect = [
_mock_response(
403,
None,
text='{"Error Message": "Special Endpoint: This symbol is not available..."}',
),
_mock_response(
403,
None,
text='{"Error Message": "Legacy Endpoint: only legacy users..."}',
),
]
mock_session_class.return_value = mock_session
client = _make_client()
client.session = mock_session
result = client.get_historical_prices("GOOG", days=10)
assert result is None
captured = capsys.readouterr()
# Both endpoints' errors must be visible.
assert "Special Endpoint" in captured.err, (
f"stable endpoint error not surfaced. stderr:\n{captured.err}"
)
assert "Legacy Endpoint" in captured.err, (
f"v3 endpoint error missing. stderr:\n{captured.err}"
)
# The user should also see which endpoint was tried first / fell back to.
assert "stable" in captured.err.lower() or "fallback" in captured.err.lower(), (
f"no fallback context in stderr:\n{captured.err}"
)
@patch("fmp_client.requests.Session")
def test_stable_success_suppresses_warning(self, mock_session_class, capsys):
"""Happy path: stable succeeds, no fallback warning emitted."""
mock_session = MagicMock()
mock_session.get.return_value = _mock_response(
200,
[
{
"symbol": "AAPL",
"date": "2026-01-01",
"open": 1.0,
"high": 1.0,
"low": 1.0,
"close": 1.0,
"volume": 1000,
}
],
)
mock_session_class.return_value = mock_session
client = _make_client()
client.session = mock_session
result = client.get_historical_prices("AAPL", days=1)
assert result is not None
captured = capsys.readouterr()
# Happy path must stay quiet — no spurious warnings.
assert "WARN" not in captured.err, f"unexpected stderr:\n{captured.err}"
assert "fallback" not in captured.err.lower()
scripts/tests/test_fmp_stable_migration.py
"""FMP /api/v3 → /stable migration tests for vcp-screener.
Proves the hardcoded-v3 call site (get_sp500_constituents) now targets
/stable, and that the stable→v3 fallback list (get_quote / get_historical
via _request_with_fallback) is left intact — i.e. a v3 fallback URL is
still attempted as a real /api/v3/ request, not rewritten back to stable.
"""
import os
import sys
from unittest.mock import MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from fmp_client import FMPClient
def _make_client():
return FMPClient(api_key="test_key") # pragma: allowlist secret
def _mock_response(status_code, json_payload, text=""):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_payload
resp.text = text
return resp
class TestHardcodedCallSiteMigratedToStable:
"""Methods that bypass the fallback list now build /stable URLs."""
def test_sp500_constituents_hits_stable(self):
client = _make_client()
seen = []
def mock_get(url, params=None, timeout=None):
seen.append((url, params or {}))
return _mock_response(200, [{"symbol": "AAPL"}, {"symbol": "MSFT"}])
client.session.get = mock_get
result = client.get_sp500_constituents()
assert len(seen) == 1
url, _ = seen[0]
assert "/stable/sp500-constituent" in url
assert "/api/v3/" not in url
assert result == [{"symbol": "AAPL"}, {"symbol": "MSFT"}]
class TestFallbackContractPreserved:
"""The stable→v3 fallback list must still reach a real /api/v3/ URL."""
def test_quote_stable_403_falls_back_to_real_v3(self):
client = _make_client()
seen = []
def mock_get(url, params=None, timeout=None):
seen.append(url)
if "stable" in url:
return _mock_response(403, None, text="Forbidden")
return _mock_response(200, [{"symbol": "^GSPC", "price": 5500.0}])
client.session.get = mock_get
result = client.get_quote("^GSPC")
# Fallback must have attempted a genuine /api/v3/ URL (not a rewritten stable URL)
assert any("/api/v3/" in u for u in seen)
assert result == [{"symbol": "^GSPC", "price": 5500.0}]
scripts/tests/test_historical_vcp.py
#!/usr/bin/env python3
"""Tests for historical single-ticker VCP scanning.
Covers:
- analyze_stock(as_of_offset=...) equivalence with pre-sliced inputs
- build_quote_from_history (no-lookahead contract)
- calculate_forward_outcome (breakout / stop-hit / timeout paths)
- HistoricalScanner walk + dedup
"""
import os
import sys
import pytest
# Reuse the synthetic-data helper from the existing test module.
sys.path.insert(0, os.path.dirname(__file__))
from test_vcp_screener import _make_prices # noqa: E402
# ===========================================================================
# analyze_stock(as_of_offset=...) — equivalence with pre-sliced inputs
# ===========================================================================
class TestAsOfOffsetSeam:
"""as_of_offset=N must produce identical output to pre-slicing the inputs."""
def test_offset_equals_pre_slice(self):
from screen_vcp import analyze_stock
# Generate enough history for trend template (>= 222 bars) and lookback.
full_hist = _make_prices(300, start=100, daily_change=0.001)
full_spy = _make_prices(300, start=400, daily_change=0.0005)
offset = 60
# When the analysis cursor is at offset 60, treat that bar as "today".
# Caller is responsible for synthesizing the quote from the slice.
as_of_bar = full_hist[offset]
year_window = full_hist[offset : offset + 252]
synth_quote = {
"price": as_of_bar["close"],
"yearHigh": max(d["high"] for d in year_window),
"yearLow": min(d["low"] for d in year_window),
"marketCap": 1e9,
"avgVolume": 1_000_000,
}
# Path A: pre-slice both arrays, as_of_offset=0
result_pre = analyze_stock(
"TEST",
full_hist[offset:],
synth_quote,
full_spy[offset:],
sector="Tech",
company_name="Test Corp",
)
# Path B: pass full arrays, as_of_offset=60
result_off = analyze_stock(
"TEST",
full_hist,
synth_quote,
full_spy,
sector="Tech",
company_name="Test Corp",
as_of_offset=offset,
)
assert result_pre is not None and result_off is not None
# Compare every numeric / categorical field that matters for downstream
# use; nested dicts (trend_template, vcp_pattern, etc.) included.
for key in (
"composite_score",
"valid_vcp",
"execution_state",
"pattern_type",
"distance_from_pivot_pct",
"sma200_distance_pct",
):
assert result_pre[key] == result_off[key], f"mismatch at {key}"
# Volume zone analysis is the path most affected by slicing — assert it
# explicitly so a regression on the as-of-offset seam is loud.
za_pre = result_pre["volume_pattern"].get("zone_analysis")
za_off = result_off["volume_pattern"].get("zone_analysis")
assert za_pre == za_off
def test_offset_zero_is_noop(self):
"""as_of_offset=0 must not change anything (backwards compatibility)."""
from screen_vcp import analyze_stock
hist = _make_prices(260, start=100, daily_change=0.001)
spy = _make_prices(260, start=400, daily_change=0.0005)
quote = {
"price": hist[0]["close"],
"yearHigh": hist[0]["close"] * 1.1,
"yearLow": hist[0]["close"] * 0.7,
"marketCap": 1e9,
"avgVolume": 1_000_000,
}
r_default = analyze_stock("TEST", hist, quote, spy)
r_explicit = analyze_stock("TEST", hist, quote, spy, as_of_offset=0)
assert r_default == r_explicit
# ===========================================================================
# Forward outcome calculator
# ===========================================================================
def _bar(date, close, low=None, high=None, volume=1_000_000):
"""Single OHLCV bar dict."""
return {
"date": date,
"open": close,
"high": high if high is not None else close * 1.01,
"low": low if low is not None else close * 0.99,
"close": close,
"adjClose": close,
"volume": volume,
}
def _hist(closes_oldest_first):
"""Build a most-recent-first history from a list of closes given
oldest-to-newest (more natural to write for forward-outcome tests).
Dates are generated via real ``datetime`` arithmetic starting at
2025-01-01, so this stays valid past the end of the month.
"""
from datetime import date, timedelta
base = date(2025, 1, 1)
bars = [
_bar((base + timedelta(days=i)).isoformat(), c, low=c * 0.99, high=c * 1.01)
for i, c in enumerate(closes_oldest_first)
]
return list(reversed(bars))
class TestForwardOutcome:
"""calculate_forward_outcome walks bars MORE RECENT than the as-of bar
(indices 0..as_of_offset-1 in the most-recent-first array)."""
def test_breakout_detected_within_window(self):
from calculators.forward_outcome import calculate_forward_outcome
# oldest .. newest: 100, 101, 102, 103, 105, 110 (breakout at day 5)
# as_of = day 0 (close=100) i.e. historical[5] in MRF
closes = [100, 101, 102, 103, 105, 110]
hist = _hist(closes)
as_of_offset = 5 # MRF index of close=100 (the oldest)
result = calculate_forward_outcome(
hist,
as_of_offset=as_of_offset,
pivot_price=104.0,
stop_price=95.0,
max_window_days=10,
)
assert result["outcome_type"] == "breakout"
assert result["days_to_outcome"] == 4 # 100 -> 101 -> 102 -> 103 -> 105
assert result["exit_price"] == pytest.approx(105.0)
# max gain measured as peak close vs as-of close
assert result["max_gain_pct"] == pytest.approx((110 - 100) / 100 * 100)
def test_stop_hit_before_breakout(self):
from calculators.forward_outcome import calculate_forward_outcome
# oldest..newest closes: 100, 99, 96, 93, 95, 98
# as_of close = 100, pivot=105, stop=95
# Forward walk: day 1 -> 99 (no), day 2 -> 96 (no, 96 > 95),
# day 3 -> 93 (< 95) -> stop_hit.
closes = [100, 99, 96, 93, 95, 98]
hist = _hist(closes)
result = calculate_forward_outcome(
hist,
as_of_offset=5,
pivot_price=105.0,
stop_price=95.0,
max_window_days=10,
)
assert result["outcome_type"] == "stop_hit"
assert result["days_to_outcome"] == 3
assert result["exit_price"] == pytest.approx(93.0)
def test_timeout_when_neither_pivot_nor_stop(self):
from calculators.forward_outcome import calculate_forward_outcome
# All forward closes between stop and pivot
closes = [100] + [101, 99, 100, 102, 98] # newest -> still in range
hist = _hist(closes)
result = calculate_forward_outcome(
hist,
as_of_offset=5,
pivot_price=110.0,
stop_price=90.0,
max_window_days=10,
)
assert result["outcome_type"] == "timeout"
assert result["days_to_outcome"] is None
assert result["exit_price"] is None
def test_insufficient_data_at_recent_offset(self):
from calculators.forward_outcome import calculate_forward_outcome
# Only 2 forward bars exist; max_window_days=10 means we time out gracefully
# but with bars_available reflecting reality.
hist = _hist([100, 101, 102])
# as_of at oldest = MRF index 2; 2 forward bars: [102, 101] reversed -> 101, 102
result = calculate_forward_outcome(
hist,
as_of_offset=2,
pivot_price=120.0,
stop_price=80.0,
max_window_days=10,
)
# Neither hit, but window was truncated by available data.
assert result["outcome_type"] == "timeout"
assert result["bars_available"] == 2
def test_zero_forward_bars(self):
from calculators.forward_outcome import calculate_forward_outcome
hist = _hist([100])
result = calculate_forward_outcome(hist, as_of_offset=0, pivot_price=110.0, stop_price=90.0)
assert result["outcome_type"] == "insufficient_data"
assert result["bars_available"] == 0
def test_max_gain_tracks_peak_even_after_stop_hit(self):
"""max_gain_pct should reflect the peak across the entire forward window,
regardless of where the outcome was resolved."""
from calculators.forward_outcome import calculate_forward_outcome
# oldest..newest: 100, 108, 92, 95, 99
# as_of=100, pivot=110, stop=95.
# day 1: 108 (gain 8%, no breakout above 110)
# day 2: 92 (stop hit, since 92 < 95)
closes = [100, 108, 92, 95, 99]
hist = _hist(closes)
result = calculate_forward_outcome(
hist,
as_of_offset=4,
pivot_price=110.0,
stop_price=95.0,
max_window_days=10,
)
assert result["outcome_type"] == "stop_hit"
assert result["max_gain_pct"] == pytest.approx(8.0, abs=0.01)
assert result["max_loss_pct"] == pytest.approx(-8.0, abs=0.01)
# ===========================================================================
# Quote synthesis (build_quote_from_history) — must not peek at future bars
# ===========================================================================
class TestBuildQuoteFromHistory:
def test_quote_fields_from_as_of_bar(self):
from historical_scanner import build_quote_from_history
hist = _make_prices(300, start=100, daily_change=0.001)
offset = 60
quote = build_quote_from_history(hist, offset)
assert quote["price"] == hist[offset]["close"]
# yearHigh/yearLow over the 252-bar window ENDING at the as-of bar
# (i.e., bars hist[offset : offset+252]). No more recent bars allowed.
window = hist[offset : offset + 252]
assert quote["yearHigh"] == max(d["high"] for d in window)
assert quote["yearLow"] == min(d["low"] for d in window)
def test_no_lookahead_into_future(self):
"""yearHigh must NOT include any bar more recent than the as-of bar.
Bars more recent than the as-of bar are at MRF indices < as_of_offset.
We poison those bars with a very high price and verify yearHigh stays
at the as-of-bar level.
"""
from historical_scanner import build_quote_from_history
hist = _make_prices(300, start=100, daily_change=0.0)
# Inject huge highs into "future" bars (more recent than offset=60).
for i in range(60):
hist[i]["high"] = 999.99
hist[i]["close"] = 999.99
quote = build_quote_from_history(hist, as_of_offset=60)
# yearHigh should reflect only bars at indices >= 60.
assert quote["yearHigh"] < 200.0, (
f"yearHigh={quote['yearHigh']} indicates lookahead into bars[0:60]"
)
def test_short_history_uses_available_window(self):
"""If fewer than 252 trailing bars exist, use what's available."""
from historical_scanner import build_quote_from_history
hist = _make_prices(120, start=100, daily_change=0.0)
quote = build_quote_from_history(hist, as_of_offset=0)
# 120 bars are all that's there; quote uses all of them
assert quote["yearHigh"] == max(d["high"] for d in hist)
assert quote["yearLow"] == min(d["low"] for d in hist)
# ===========================================================================
# Historical scanner walk + dedup
# ===========================================================================
def _fake_vcp_result(symbol, pivot, t1_high_date, last_low_date):
"""Minimal analyze_stock-shaped dict used to inject fake detections."""
return {
"symbol": symbol,
"valid_vcp": True,
"composite_score": 75.0,
"rating": "Good VCP",
"execution_state": "Pre-breakout",
"pattern_type": "Textbook VCP",
"distance_from_pivot_pct": 1.2,
"vcp_pattern": {
"pivot_price": pivot,
"valid_vcp": True,
"num_contractions": 2,
"contractions": [
{
"label": "T1",
"high_date": t1_high_date,
"low_date": "2024-01-05",
"high_price": pivot,
"low_price": pivot * 0.85,
"depth_pct": 15.0,
},
{
"label": "T2",
"high_date": "2024-01-25",
"low_date": last_low_date,
"high_price": pivot * 0.99,
"low_price": pivot * 0.92,
"depth_pct": 7.0,
},
],
},
"volume_pattern": {"score": 70, "dry_up_ratio": 0.5},
"pivot_proximity": {"score": 90, "risk_pct": 5.0},
"trend_template": {"score": 90},
"relative_strength": {"score": 80},
}
class TestHistoricalScannerDedup:
def test_same_pattern_detected_at_multiple_offsets_yields_one_detection(self, monkeypatch):
"""If analyze_stock keeps reporting the same (T1_high_date, last_low_date,
pivot) as the cursor strides forward, dedup should collapse to 1."""
from historical_scanner import scan_history
hist = _make_prices(400, start=100, daily_change=0.001)
spy = _make_prices(400, start=400, daily_change=0.0005)
# Inject a fake analyze_stock that always returns the same VCP.
def fake_analyze(symbol, historical, quote, sp500, **kw):
offset = kw.get("as_of_offset", 0)
r = _fake_vcp_result(
"TEST", pivot=110.0, t1_high_date="2024-01-01", last_low_date="2024-02-15"
)
# Vary something irrelevant to the dedup key so we can confirm
# multiple invocations happened.
r["_offset"] = offset
return r
monkeypatch.setattr("historical_scanner.screen_vcp.analyze_stock", fake_analyze)
result = scan_history("TEST", hist, spy, stride_days=10)
assert len(result) == 1
def test_distinct_patterns_kept(self, monkeypatch):
"""Different (high_date, low_date, pivot) keys should NOT be deduped."""
from historical_scanner import scan_history
hist = _make_prices(400, start=100, daily_change=0.001)
spy = _make_prices(400, start=400, daily_change=0.0005)
def fake_analyze(symbol, historical, quote, sp500, **kw):
offset = kw.get("as_of_offset", 0)
# Return a "different" pattern at each offset by varying the dates.
return _fake_vcp_result(
"TEST",
pivot=100 + offset,
t1_high_date=f"2024-01-{offset:03d}",
last_low_date=f"2024-02-{offset:03d}",
)
monkeypatch.setattr("historical_scanner.screen_vcp.analyze_stock", fake_analyze)
result = scan_history("TEST", hist, spy, stride_days=20)
assert len(result) > 5
def test_chronological_order_oldest_first(self, monkeypatch):
from historical_scanner import scan_history
hist = _make_prices(400, start=100, daily_change=0.001)
spy = _make_prices(400, start=400, daily_change=0.0005)
def fake_analyze(symbol, historical, quote, sp500, **kw):
offset = kw.get("as_of_offset", 0)
# Use the as-of bar's date so output ordering reflects time.
return _fake_vcp_result(
"TEST",
pivot=100.0 + offset,
t1_high_date=historical[offset]["date"],
last_low_date=historical[max(offset - 10, 0)]["date"],
)
monkeypatch.setattr("historical_scanner.screen_vcp.analyze_stock", fake_analyze)
result = scan_history("TEST", hist, spy, stride_days=30)
dates = [r["as_of_date"] for r in result]
assert dates == sorted(dates)
def test_short_history_returns_empty(self):
"""History shorter than lookback + a small buffer returns []."""
from historical_scanner import scan_history
hist = _make_prices(100, start=100, daily_change=0.001)
spy = _make_prices(100, start=400, daily_change=0.0005)
assert scan_history("TEST", hist, spy, lookback_days=120) == []
class TestHistoryFlagParsing:
"""--history takes an optional integer (trading days to scan).
Bare --history defaults to the canonical 5-year window."""
def _parse(self, *cli_args):
import sys as _sys
from screen_vcp import parse_arguments
original = _sys.argv
try:
_sys.argv = ["screen_vcp.py", *cli_args]
return parse_arguments()
finally:
_sys.argv = original
def test_bare_history_uses_default(self):
args = self._parse("--history", "--ticker", "AAPL")
# 5 years × 252 trading days = 1260 — preserves prior default behavior.
assert args.history == 1260
assert args.ticker == "AAPL"
def test_history_with_explicit_days(self):
args = self._parse("--history", "500", "--ticker", "AAPL")
assert args.history == 500
def test_history_with_equals_syntax(self):
args = self._parse("--history=2520", "--ticker", "TSLA")
assert args.history == 2520
def test_history_omitted_means_cross_sectional_mode(self):
# Without --history, the cross-sectional pipeline should run; args.history is None.
args = self._parse("--universe", "AAPL", "MSFT")
assert args.history is None
def test_history_requires_ticker(self):
import pytest as _pytest
with _pytest.raises(SystemExit):
self._parse("--history") # no --ticker
def test_history_rejects_out_of_range(self):
import pytest as _pytest
with _pytest.raises(SystemExit):
self._parse("--history", "50", "--ticker", "AAPL") # too short
with _pytest.raises(SystemExit):
self._parse("--history", "999999", "--ticker", "AAPL") # too long
class TestSanitizeTicker:
"""sanitize_ticker is the first line of defence against path injection
through the --ticker CLI flag."""
def test_accepts_normal_symbols(self):
from historical_scanner import sanitize_ticker
assert sanitize_ticker("FIX") == "FIX"
assert sanitize_ticker("tsla") == "TSLA"
assert sanitize_ticker("BRK.B") == "BRK.B"
assert sanitize_ticker("BF-B") == "BF-B"
def test_rejects_path_traversal(self):
from historical_scanner import sanitize_ticker
for evil in (
"../etc/passwd",
"../../FOO",
"FOO/BAR",
"FOO\\BAR",
"FOO BAR",
"",
"1FOO",
"FOO;rm -rf /",
):
with pytest.raises(ValueError):
sanitize_ticker(evil)
# ===========================================================================
# Historical report writers
# ===========================================================================
class TestHistoricalReport:
def _sample_detection(
self, as_of="2024-03-15", outcome_type="breakout", days=10, gain=12.0, loss=-3.0
):
return {
"symbol": "TEST",
"as_of_date": as_of,
"composite_score": 82.5,
"rating": "Strong VCP",
"execution_state": "Pre-breakout",
"pattern_type": "Textbook VCP",
"vcp_pattern": {
"pivot_price": 105.50,
"num_contractions": 3,
"pattern_duration_days": 45,
"contractions": [
{
"label": "T1",
"high_date": "2024-02-01",
"high_price": 105.50,
"low_date": "2024-02-15",
"low_price": 95.00,
"depth_pct": 9.9,
"duration_days": 14,
},
{
"label": "T2",
"high_date": "2024-02-20",
"high_price": 104.30,
"low_date": "2024-03-01",
"low_price": 99.50,
"depth_pct": 4.6,
"duration_days": 10,
},
],
},
"forward_outcome": {
"outcome_type": outcome_type,
"days_to_outcome": days,
"max_gain_pct": gain,
"max_loss_pct": loss,
"bars_evaluated": 60,
},
}
def test_json_report_written(self, tmp_path):
from historical_report import generate_historical_json_report
out = tmp_path / "hist.json"
detections = [self._sample_detection()]
generate_historical_json_report("TEST", detections, {"generated_at": "now"}, str(out))
import json as _json
data = _json.loads(out.read_text(encoding="utf-8"))
assert data["symbol"] == "TEST"
assert data["summary"]["total"] == 1
assert data["summary"]["breakouts"] == 1
def test_markdown_report_written(self, tmp_path):
from historical_report import generate_historical_markdown_report
out = tmp_path / "hist.md"
detections = [
self._sample_detection(
as_of="2024-03-15", outcome_type="breakout", days=10, gain=12.0, loss=-3.0
),
self._sample_detection(
as_of="2024-08-22", outcome_type="stop_hit", days=4, gain=2.1, loss=-8.5
),
self._sample_detection(
as_of="2025-01-10", outcome_type="timeout", days=None, gain=5.0, loss=-2.0
),
]
generate_historical_markdown_report(
"TEST",
detections,
{
"generated_at": "2026-05-19",
"stride_days": 5,
"lookback_days": 120,
"outcome_days": 60,
"history_range": "2020-01 to 2026-05",
},
str(out),
)
text = out.read_text(encoding="utf-8")
assert "VCP History" in text
assert "Detection Timeline" in text
# All three outcomes should appear in the summary table
assert "breakout" in text
assert "stop_hit" in text
assert "timeout" in text
def test_markdown_empty_detections(self, tmp_path):
from historical_report import generate_historical_markdown_report
out = tmp_path / "empty.md"
generate_historical_markdown_report("FOO", [], {"generated_at": "now"}, str(out))
text = out.read_text(encoding="utf-8")
assert "No VCP detections" in text
# ===========================================================================
# End-to-end: run_historical with a mocked FMP client
# ===========================================================================
class _StubFMPClient:
"""Minimal FMPClient stand-in for end-to-end testing of run_historical.
Returns the synthetic histories supplied at construction time and tracks
api stats."""
def __init__(self, ticker_history, spy_history):
self._ticker = ticker_history
self._spy = spy_history
self.api_calls_made = 0
self.cache = {}
def get_historical_prices(self, symbol, days=365):
self.api_calls_made += 1
if symbol == "SPY":
return {"symbol": "SPY", "historical": self._spy}
return {"symbol": symbol, "historical": self._ticker}
def get_api_stats(self):
return {
"cache_entries": len(self.cache),
"api_calls_made": self.api_calls_made,
"rate_limit_reached": False,
}
class TestRunHistoricalE2E:
def test_end_to_end_dispatch_writes_reports(self, tmp_path):
"""run_historical() must fetch history, sweep, and write both reports."""
import types
from screen_vcp import run_historical
# Build a long, mostly trending synthetic history. We don't require
# detections to occur — only that the dispatch + report layer survives
# a real call with synthetic data.
hist = _make_prices(1500, start=100, daily_change=0.0005)
spy = _make_prices(1500, start=400, daily_change=0.0003)
client = _StubFMPClient(hist, spy)
args = types.SimpleNamespace(
ticker="TEST",
history=1260,
stride_days=20, # coarse stride keeps test fast
outcome_days=60,
lookback_days=120,
output_dir=str(tmp_path),
ext_threshold=8.0,
min_contractions=2,
t1_depth_min=10.0,
contraction_ratio=0.70,
atr_multiplier=1.5,
min_contraction_days=5,
breakout_volume_ratio=1.5,
max_sma200_extension=50.0,
wide_and_loose_threshold=15.0,
)
run_historical(args, client)
# The dispatcher must always write both report files, even with zero
# detections — empty timeline is a valid result.
files = list(tmp_path.iterdir())
json_files = [f for f in files if f.name.endswith(".json")]
md_files = [f for f in files if f.name.endswith(".md")]
assert len(json_files) == 1, files
assert len(md_files) == 1, files
assert "TEST" in json_files[0].name
assert "vcp_history" in json_files[0].name
# Verify the JSON is well-formed and has expected top-level keys.
import json as _json
data = _json.loads(json_files[0].read_text(encoding="utf-8"))
assert data["symbol"] == "TEST"
assert "detections" in data
assert "summary" in data
assert data["metadata"]["stride_days"] == 20
scripts/tests/test_vcp_screener.py
#!/usr/bin/env python3
"""
Tests for VCP Screener modules.
Covers boundary conditions for VCP pattern detection, contraction validation,
Trend Template criteria, volume patterns, pivot proximity, and scoring.
"""
import json
import os
import tempfile
from unittest import mock
import pytest
from calculators.pivot_proximity_calculator import calculate_pivot_proximity
from calculators.relative_strength_calculator import calculate_relative_strength
from calculators.trend_template_calculator import calculate_trend_template
from calculators.vcp_pattern_calculator import _validate_vcp, calculate_vcp_pattern
from calculators.volume_pattern_calculator import calculate_volume_pattern
from report_generator import generate_json_report, generate_markdown_report
from scorer import calculate_composite_score
from screen_vcp import (
analyze_stock,
compute_entry_ready,
is_stale_price,
parse_arguments,
passes_trend_filter,
pre_filter_stock,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_prices(n, start=100.0, daily_change=0.0, volume=1000000):
"""Generate synthetic price data (most-recent-first)."""
prices = []
p = start
for i in range(n):
p_day = p * (1 + daily_change * (n - i)) # linear drift
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": round(p_day, 2),
"high": round(p_day * 1.01, 2),
"low": round(p_day * 0.99, 2),
"close": round(p_day, 2),
"adjClose": round(p_day, 2),
"volume": volume,
}
)
return prices
def _make_vcp_contractions(depths, high_price=100.0):
"""Build contraction dicts for _validate_vcp testing."""
contractions = []
hp = high_price
for i, depth in enumerate(depths):
lp = hp * (1 - depth / 100)
contractions.append(
{
"label": f"T{i + 1}",
"high_idx": i * 20,
"high_price": round(hp, 2),
"high_date": f"2025-01-{i * 20 + 1:02d}",
"low_idx": i * 20 + 10,
"low_price": round(lp, 2),
"low_date": f"2025-01-{i * 20 + 11:02d}",
"depth_pct": round(depth, 2),
}
)
hp = hp * 0.99 # next high slightly lower
return contractions
# ===========================================================================
# VCP Pattern Validation Tests (Fix 1: contraction ratio 0.75 rule)
# ===========================================================================
class TestVCPValidation:
"""Test the strict 75% contraction ratio rule."""
def test_valid_tight_contractions(self):
"""T1=20%, T2=10%, T3=5% -> ratios 0.50, 0.50 -> valid"""
contractions = _make_vcp_contractions([20, 10, 5])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is True
def test_invalid_loose_contractions(self):
"""T1=20%, T2=18% -> ratio 0.90 > 0.75 -> invalid"""
contractions = _make_vcp_contractions([20, 18])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is False
assert any("0.75" in issue for issue in result["issues"])
def test_borderline_ratio_075(self):
"""T1=20%, T2=15% -> ratio 0.75 -> valid (exactly at threshold)"""
contractions = _make_vcp_contractions([20, 15])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is True
def test_ratio_076_invalid(self):
"""T1=20%, T2=15.2% -> ratio 0.76 -> invalid"""
contractions = _make_vcp_contractions([20, 15.2])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is False
def test_expanding_contractions_invalid(self):
"""T1=10%, T2=15% -> ratio 1.5 -> invalid"""
contractions = _make_vcp_contractions([10, 15])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is False
def test_single_contraction_too_few(self):
"""Single contraction is not enough for VCP."""
contractions = _make_vcp_contractions([20])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is False
def test_t1_too_shallow(self):
"""T1=5% is below 8% minimum -> invalid"""
contractions = _make_vcp_contractions([5, 3])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is False
def test_four_progressive_contractions(self):
"""T1=30%, T2=15%, T3=7%, T4=3% -> valid textbook"""
contractions = _make_vcp_contractions([30, 15, 7, 3])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is True
# ===========================================================================
# Stale Price (Acquisition) Filter Tests
# ===========================================================================
class TestStalePrice:
"""Test is_stale_price() - detects acquired/pinned stocks."""
def test_stale_flat_price(self):
"""Daily range < 1% for 10 days -> stale."""
prices = []
for i in range(20):
prices.append(
{
"date": f"2026-01-{20 - i:02d}",
"open": 14.31,
"high": 14.35,
"low": 14.28,
"close": 14.31,
"volume": 500000,
}
)
assert is_stale_price(prices) is True
def test_normal_price_action(self):
"""Normal volatility -> not stale."""
prices = []
for i in range(20):
base = 100.0 + i * 0.5
prices.append(
{
"date": f"2026-01-{20 - i:02d}",
"open": base,
"high": base * 1.02,
"low": base * 0.98,
"close": base + 0.3,
"volume": 1000000,
}
)
assert is_stale_price(prices) is False
def test_insufficient_data(self):
"""Less than lookback days -> not stale (let other filters handle)."""
prices = [{"date": "2026-01-01", "high": 10, "low": 10, "close": 10}]
assert is_stale_price(prices) is False
# ===========================================================================
# Trend Template Tests (Fix 5: C3 conservative with limited data)
# ===========================================================================
class TestTrendTemplate:
"""Test Trend Template scoring."""
def test_insufficient_data(self):
prices = _make_prices(30)
quote = {"price": 100, "yearHigh": 110, "yearLow": 50}
result = calculate_trend_template(prices, quote)
assert result["score"] == 0
assert result["passed"] is False
def test_c3_fails_with_200_days(self):
"""With exactly 200 days, C3 should fail (cannot verify 22d SMA200 trend)."""
prices = _make_prices(210, start=100, daily_change=0.001)
quote = {"price": 120, "yearHigh": 125, "yearLow": 80}
result = calculate_trend_template(prices, quote, rs_rank=85)
c3 = result["criteria"].get("c3_sma200_trending_up", {})
assert c3["passed"] is False
def test_c3_passes_with_222_days(self):
"""With 222+ days and uptrend, C3 should pass."""
prices = _make_prices(250, start=80, daily_change=0.001)
quote = {"price": 120, "yearHigh": 125, "yearLow": 70}
result = calculate_trend_template(prices, quote, rs_rank=85)
# C3 should be evaluated (may pass or fail depending on synthetic data)
c3 = result["criteria"].get("c3_sma200_trending_up", {})
assert "Cannot verify" not in c3.get("detail", "")
# ===========================================================================
# Volume Pattern Tests
# ===========================================================================
class TestVolumePattern:
def test_insufficient_data(self):
result = calculate_volume_pattern([])
assert result["score"] == 0
assert "Insufficient" in result["error"]
def test_low_dry_up_ratio(self):
"""Recent volume much lower than 50d avg -> high score.
Bar[0] is excluded from dry-up (potential breakout bar).
The dry-up window is volumes[1:11]. Set bars 0-10 to low volume
so that all 10 dry-up bars have low volume.
"""
prices = _make_prices(60, volume=1000000)
# Override bars 0-10 (11 bars) with low volume so volumes[1:11] are all low
for i in range(11):
prices[i]["volume"] = 200000
result = calculate_volume_pattern(prices)
assert result["dry_up_ratio"] < 0.3
assert result["score"] >= 80
class TestZoneVolumeIndexContract:
"""Regression: contraction indices are chronological within the lookback
window; _zone_volume_analysis maps them back via n - 1 - chrono_idx where
n = len(volumes). Callers must pass volumes whose length matches the
lookback window used to compute the contractions, otherwise the reverse
mapping indexes into the wrong bars.
"""
def test_zone_a_volume_correct_when_history_matches_lookback(self):
"""When the caller passes a 120-bar slice (matching the lookback window
the contractions were built from), zone_a_avg_volume reflects the actual
contraction bars."""
# 120 recent bars; mark contraction-area volumes distinctly.
prices = _make_prices(120, start=100, volume=1_000_000)
# Contraction (T1) at chronological indices 50..70 within the lookback slice.
# Reverse mapping: rev_idx = 120 - 1 - chrono_idx, so MRF indices 49..69.
for i in range(49, 70):
prices[i]["volume"] = 50_000 # very low volume in the contraction
contractions = [
{
"label": "T1",
"high_idx": 50,
"high_price": 110.0,
"high_date": "2025-01-50",
"low_idx": 70,
"low_price": 95.0,
"low_date": "2025-01-70",
"depth_pct": 13.6,
"duration_days": 20,
}
]
result = calculate_volume_pattern(prices, pivot_price=110.0, contractions=contractions)
za = result["zone_analysis"]["zone_a_avg_volume"]
# Average of the 21 bars at the low-volume contraction region.
assert 40_000 <= za <= 60_000, f"zone_a should reflect contraction bars (~50k), got {za}"
def test_zone_a_volume_wrong_when_history_exceeds_lookback(self):
"""If the caller passes a longer history (e.g., 260 bars) but contraction
indices were built from a 120-bar slice, _zone_volume_analysis will read
from the wrong region. This test documents the calculator's contract;
analyze_stock() must pre-slice to avoid it.
"""
# Recent 120 bars: contraction at chronological 50..70 (MRF 49..69), low vol.
recent = _make_prices(120, start=100, volume=1_000_000)
for i in range(49, 70):
recent[i]["volume"] = 50_000
# Older 140 bars: high volume — should NOT be read by zone_a if contracts
# were detected only within the 120-bar lookback.
older = _make_prices(140, start=80, volume=20_000_000)
full = recent + older # most-recent-first
contractions = [
{
"label": "T1",
"high_idx": 50,
"high_price": 110.0,
"high_date": "2025-01-50",
"low_idx": 70,
"low_price": 95.0,
"low_date": "2025-01-70",
"depth_pct": 13.6,
"duration_days": 20,
}
]
result_full = calculate_volume_pattern(full, pivot_price=110.0, contractions=contractions)
za_full = result_full["zone_analysis"]["zone_a_avg_volume"]
# With n=260, rev_idx = 260 - 1 - 70 = 189 .. 260 - 1 - 50 = 209,
# which lies in the OLDER (20M) region. Caller-side fix must avoid this.
assert za_full > 10_000_000, (
f"Expected the bug to manifest (zone_a reads older bars ~20M); got {za_full}. "
"This test documents the contract — pass historical[:lookback_days]."
)
def test_analyze_stock_slices_history_for_volume_calculator(self):
"""analyze_stock must slice historical to lookback_days before calling
calculate_volume_pattern, to preserve the chronological index contract
between vcp_pattern_calculator and volume_pattern_calculator.
"""
history_260 = _make_prices(260, start=100, daily_change=0.001)
sp500_260 = _make_prices(260, start=400, daily_change=0.0005)
quote = {
"price": history_260[0]["close"],
"yearHigh": history_260[0]["close"] * 1.1,
"yearLow": history_260[0]["close"] * 0.6,
"marketCap": 1e9,
"avgVolume": 1_000_000,
}
lookback = 120
captured_lengths = []
original = calculate_volume_pattern
def _capture(historical_prices, *args, **kwargs):
captured_lengths.append(len(historical_prices))
return original(historical_prices, *args, **kwargs)
with mock.patch("screen_vcp.calculate_volume_pattern", side_effect=_capture):
analyze_stock("TEST", history_260, quote, sp500_260, lookback_days=lookback)
assert captured_lengths, "calculate_volume_pattern was not called"
assert captured_lengths[0] == lookback, (
f"analyze_stock should have passed a {lookback}-bar slice to "
f"calculate_volume_pattern; got {captured_lengths[0]} bars. "
"This is the regression for the chronological-index bug."
)
# ===========================================================================
# Pivot Proximity Tests
# ===========================================================================
class TestPivotProximity:
def test_no_pivot(self):
result = calculate_pivot_proximity(100.0, None)
assert result["score"] == 0
def test_breakout_confirmed(self):
"""0-3% above with volume -> base 90 + bonus 10 = 100, BREAKOUT CONFIRMED."""
result = calculate_pivot_proximity(
102.0, 100.0, last_contraction_low=95.0, breakout_volume=True
)
assert result["score"] == 100
assert result["trade_status"] == "BREAKOUT CONFIRMED"
def test_at_pivot(self):
result = calculate_pivot_proximity(99.0, 100.0, last_contraction_low=95.0)
assert result["score"] == 90
assert "AT PIVOT" in result["trade_status"]
def test_far_below_pivot(self):
result = calculate_pivot_proximity(80.0, 100.0)
assert result["score"] == 10
def test_below_stop_level(self):
result = calculate_pivot_proximity(90.0, 100.0, last_contraction_low=95.0)
assert "BELOW STOP LEVEL" in result["trade_status"]
assert result["score"] == 0
assert result["risk_pct"] is None
def test_extended_above_pivot_7pct(self):
"""7% above pivot (no volume) -> score=50, High chase risk."""
result = calculate_pivot_proximity(107.0, 100.0, last_contraction_low=95.0)
assert result["score"] == 50
assert "High chase risk" in result["trade_status"]
def test_extended_above_pivot_25pct(self):
"""25% above pivot -> score=20, OVEREXTENDED."""
result = calculate_pivot_proximity(125.0, 100.0, last_contraction_low=95.0)
assert result["score"] == 20
assert "OVEREXTENDED" in result["trade_status"]
def test_near_above_pivot_2pct(self):
"""2% above pivot (no volume) -> score=90, ABOVE PIVOT."""
result = calculate_pivot_proximity(102.0, 100.0, last_contraction_low=95.0)
assert result["score"] == 90
assert "ABOVE PIVOT" in result["trade_status"]
# --- New distance-priority tests ---
def test_breakout_volume_no_override_at_33pct(self):
"""+33.5% above, volume=True -> score=20 (distance priority, no bonus >5%)."""
result = calculate_pivot_proximity(
133.5, 100.0, last_contraction_low=95.0, breakout_volume=True
)
assert result["score"] == 20
assert "OVEREXTENDED" in result["trade_status"]
def test_breakout_volume_bonus_at_2pct(self):
"""+2% above, volume=True -> base 90 + bonus 10 = 100."""
result = calculate_pivot_proximity(
102.0, 100.0, last_contraction_low=95.0, breakout_volume=True
)
assert result["score"] == 100
assert result["trade_status"] == "BREAKOUT CONFIRMED"
def test_breakout_volume_bonus_at_4pct(self):
"""+4% above, volume=True -> base 65 + bonus 10 = 75."""
result = calculate_pivot_proximity(
104.0, 100.0, last_contraction_low=95.0, breakout_volume=True
)
assert result["score"] == 75
assert "vol confirmed" in result["trade_status"]
def test_breakout_volume_no_bonus_at_7pct(self):
"""+7% above, volume=True -> score=50 (no bonus >5%)."""
result = calculate_pivot_proximity(
107.0, 100.0, last_contraction_low=95.0, breakout_volume=True
)
assert result["score"] == 50
assert "High chase risk" in result["trade_status"]
# ===========================================================================
# Relative Strength Tests
# ===========================================================================
class TestRelativeStrength:
def test_insufficient_stock_data(self):
result = calculate_relative_strength([], [])
assert result["score"] == 0
def test_outperformer(self):
# Stock up 30%, SP500 up 5% over 3 months
stock = _make_prices(70, start=77, daily_change=0.003)
sp500 = _make_prices(70, start=95, daily_change=0.0005)
result = calculate_relative_strength(stock, sp500)
assert result["score"] >= 60 # should outperform
# ===========================================================================
# Entry Ready Tests
# ===========================================================================
class TestEntryReady:
"""Test compute_entry_ready() from screen_vcp module."""
def _make_result(
self,
valid_vcp=True,
distance_from_pivot_pct=-1.0,
dry_up_ratio=0.5,
risk_pct=5.0,
):
"""Build a minimal analysis result dict for compute_entry_ready()."""
return {
"valid_vcp": valid_vcp,
"distance_from_pivot_pct": distance_from_pivot_pct,
"volume_pattern": {"dry_up_ratio": dry_up_ratio},
"pivot_proximity": {"risk_pct": risk_pct},
}
def test_entry_ready_ideal_candidate(self):
"""valid_vcp=True, distance=-1%, dry_up=0.5, risk=5% -> True."""
result = self._make_result(
valid_vcp=True,
distance_from_pivot_pct=-1.0,
dry_up_ratio=0.5,
risk_pct=5.0,
)
assert compute_entry_ready(result) is True
def test_entry_ready_false_extended(self):
"""valid_vcp=True, distance=+15% -> False (too far above pivot)."""
result = self._make_result(
valid_vcp=True,
distance_from_pivot_pct=15.0,
dry_up_ratio=0.5,
risk_pct=5.0,
)
assert compute_entry_ready(result) is False
def test_entry_ready_false_invalid_vcp(self):
"""valid_vcp=False -> False regardless of distance."""
result = self._make_result(
valid_vcp=False,
distance_from_pivot_pct=-1.0,
dry_up_ratio=0.5,
risk_pct=5.0,
)
assert compute_entry_ready(result) is False
def test_entry_ready_false_high_risk(self):
"""valid_vcp=True, distance=-1%, risk=20% -> False (risk too high)."""
result = self._make_result(
valid_vcp=True,
distance_from_pivot_pct=-1.0,
dry_up_ratio=0.5,
risk_pct=20.0,
)
assert compute_entry_ready(result) is False
def test_entry_ready_custom_max_above_pivot(self):
"""CLI --max-above-pivot=5.0 allows +4% above pivot."""
result = self._make_result(distance_from_pivot_pct=4.0)
assert compute_entry_ready(result, max_above_pivot=5.0) is True
assert compute_entry_ready(result, max_above_pivot=3.0) is False
def test_entry_ready_custom_max_risk(self):
"""CLI --max-risk=10.0 rejects risk=12%."""
result = self._make_result(risk_pct=12.0)
assert compute_entry_ready(result, max_risk=15.0) is True
assert compute_entry_ready(result, max_risk=10.0) is False
def test_entry_ready_no_require_valid_vcp(self):
"""CLI --no-require-valid-vcp allows invalid VCP."""
result = self._make_result(valid_vcp=False)
assert compute_entry_ready(result, require_valid_vcp=True) is False
assert compute_entry_ready(result, require_valid_vcp=False) is True
# ===========================================================================
# Scorer Tests
# ===========================================================================
class TestScorer:
def test_textbook_rating(self):
result = calculate_composite_score(100, 100, 100, 100, 100)
assert result["composite_score"] == 100
assert result["rating"] == "Textbook VCP"
def test_no_vcp_rating(self):
result = calculate_composite_score(0, 0, 0, 0, 0)
assert result["composite_score"] == 0
assert result["rating"] == "No VCP"
def test_weights_sum_to_100(self):
"""Verify component weights sum to 1.0"""
from scorer import COMPONENT_WEIGHTS
total = sum(COMPONENT_WEIGHTS.values())
assert abs(total - 1.0) < 0.001
def test_valid_vcp_false_caps_rating(self):
"""valid_vcp=False with composite>=70 -> rating capped to 'Developing VCP'."""
# Scores: 80*0.25 + 70*0.25 + 70*0.20 + 70*0.15 + 70*0.15 = 72.5
result = calculate_composite_score(80, 70, 70, 70, 70, valid_vcp=False)
assert result["composite_score"] >= 70
assert result["rating"] == "Developing VCP"
assert "not confirmed" in result["rating_description"].lower()
assert result["valid_vcp"] is False
def test_valid_vcp_true_no_cap(self):
"""valid_vcp=True with composite>=70 -> normal rating (Good VCP)."""
result = calculate_composite_score(80, 70, 70, 70, 70, valid_vcp=True)
assert result["composite_score"] >= 70
assert result["rating"] == "Good VCP"
assert result["valid_vcp"] is True
def test_valid_vcp_false_low_score_no_effect(self):
"""valid_vcp=False with composite<70 -> no cap needed, normal rating."""
# Scores: 60*0.25 + 50*0.25 + 50*0.20 + 50*0.15 + 50*0.15 = 52.5
result = calculate_composite_score(60, 50, 50, 50, 50, valid_vcp=False)
assert result["composite_score"] < 70
assert result["rating"] == "Weak VCP"
assert result["valid_vcp"] is False
# ===========================================================================
# Report Generator Tests (Fix 2: market_cap=None, Fix 3/4: summary counts)
# ===========================================================================
class TestReportGenerator:
def _make_stock(self, symbol="TEST", score=75.0, market_cap=50e9, rating=None):
if rating is None:
if score >= 90:
rating = "Textbook VCP"
elif score >= 80:
rating = "Strong VCP"
elif score >= 70:
rating = "Good VCP"
elif score >= 60:
rating = "Developing VCP"
elif score >= 50:
rating = "Weak VCP"
else:
rating = "No VCP"
return {
"symbol": symbol,
"company_name": f"{symbol} Corp",
"sector": "Technology",
"price": 150.0,
"market_cap": market_cap,
"composite_score": score,
"rating": rating,
"rating_description": "Solid VCP",
"guidance": "Buy on volume confirmation",
"weakest_component": "Volume",
"weakest_score": 40,
"strongest_component": "Trend",
"strongest_score": 100,
"trend_template": {"score": 100, "criteria_passed": 7},
"vcp_pattern": {
"score": 70,
"num_contractions": 2,
"contractions": [],
"pivot_price": 145.0,
},
"volume_pattern": {"score": 40, "dry_up_ratio": 0.8},
"pivot_proximity": {
"score": 75,
"distance_from_pivot_pct": -3.0,
"stop_loss_price": 140.0,
"risk_pct": 7.0,
"trade_status": "NEAR PIVOT",
},
"relative_strength": {"score": 80, "rs_rank_estimate": 80, "weighted_rs": 15.0},
}
def test_market_cap_none(self):
"""market_cap=None should not crash."""
with tempfile.TemporaryDirectory() as tmpdir:
stock = self._make_stock(market_cap=None)
md_file = os.path.join(tmpdir, "test.md")
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {},
"api_stats": {},
}
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "N/A" in content # market cap should show N/A
def test_summary_uses_all_results(self):
"""Summary should count all candidates, not just top N."""
all_results = [self._make_stock(f"S{i}", score=90 - i * 5) for i in range(10)]
top_results = all_results[:3]
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {"vcp_candidates": 10},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
json_file = os.path.join(tmpdir, "test.json")
generate_json_report(top_results, metadata, json_file, all_results=all_results)
with open(json_file) as f:
data = json.load(f)
assert data["summary"]["total"] == 10
assert len(data["results"]) == 3
def test_market_cap_zero(self):
"""market_cap=0 should show N/A."""
with tempfile.TemporaryDirectory() as tmpdir:
stock = self._make_stock(market_cap=0)
md_file = os.path.join(tmpdir, "test.md")
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {},
"api_stats": {},
}
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "N/A" in content
def test_top_greater_than_20(self):
"""--top=25 should produce 25 entries in Markdown, not capped at 20."""
stocks = [self._make_stock(f"S{i:02d}", score=95 - i) for i in range(25)]
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {"vcp_candidates": 25},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report(stocks, metadata, md_file)
with open(md_file) as f:
content = f.read()
# All 25 stocks should appear in Section A or B
assert "Section A:" in content or "Section B:" in content
for i in range(25):
assert f"S{i:02d}" in content
def test_report_two_sections(self):
"""Report splits into Pre-Breakout Watchlist and Extended sections."""
entry_ready_stock = self._make_stock("READY", score=80.0, rating="Strong VCP")
entry_ready_stock["entry_ready"] = True
entry_ready_stock["distance_from_pivot_pct"] = -1.0
extended_stock = self._make_stock("EXTENDED", score=75.0, rating="Good VCP")
extended_stock["entry_ready"] = False
extended_stock["distance_from_pivot_pct"] = 15.0
results = [entry_ready_stock, extended_stock]
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {"vcp_candidates": 2},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report(results, metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "Pre-Breakout Watchlist" in content
assert "Extended / Quality VCP" in content
assert "READY" in content
assert "EXTENDED" in content
def test_summary_counts_by_rating_not_score(self):
"""Summary should use rating field, not composite_score.
A stock with composite=72 but rating='Developing VCP' (valid_vcp cap)
must count as developing, not good.
"""
from report_generator import _generate_summary
results = [
# Normal: composite=75, rating=Good VCP
self._make_stock("GOOD1", score=75.0, rating="Good VCP"),
# Capped: composite=72 but valid_vcp=False -> Developing VCP
self._make_stock("CAPPED", score=72.0, rating="Developing VCP"),
# Normal developing
self._make_stock("DEV1", score=65.0, rating="Developing VCP"),
# Weak
self._make_stock("WEAK1", score=55.0, rating="Weak VCP"),
]
summary = _generate_summary(results)
assert summary["total"] == 4
assert summary["good"] == 1 # only GOOD1
assert summary["developing"] == 2 # CAPPED + DEV1
assert summary["weak"] == 1 # WEAK1
assert summary["textbook"] == 0
assert summary["strong"] == 0
# ===========================================================================
# SMA50 Extended Penalty Tests
# ===========================================================================
class TestSMA50ExtendedPenalty:
"""Test extended penalty applied to trend template score."""
def _make_stage2_prices(self, n=250, sma50_target=100.0, price=None):
"""Build synthetic prices where SMA50 ≈ sma50_target.
All prices are constant at sma50_target so SMA50 = sma50_target exactly.
The quote price is set separately to control distance.
"""
prices = []
for i in range(n):
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": sma50_target,
"high": sma50_target * 1.005,
"low": sma50_target * 0.995,
"close": sma50_target,
"adjClose": sma50_target,
"volume": 1000000,
}
)
return prices
def _run_tt(self, distance_pct, ext_threshold=8.0):
"""Run calculate_trend_template with a given SMA50 distance %.
Returns the result dict.
"""
sma50_target = 100.0
price = sma50_target * (1 + distance_pct / 100)
prices = self._make_stage2_prices(n=250, sma50_target=sma50_target)
quote = {
"price": price,
"yearHigh": price * 1.05,
"yearLow": sma50_target * 0.6,
}
return calculate_trend_template(
prices,
quote,
rs_rank=85,
ext_threshold=ext_threshold,
)
# --- Penalty calculation ---
def test_no_penalty_within_8pct(self):
result = self._run_tt(5.0)
assert result["extended_penalty"] == 0
def test_penalty_at_10pct_distance(self):
result = self._run_tt(10.0)
assert result["extended_penalty"] == -5
def test_penalty_at_15pct_distance(self):
result = self._run_tt(15.0)
assert result["extended_penalty"] == -10
def test_penalty_at_20pct_distance(self):
result = self._run_tt(20.0)
assert result["extended_penalty"] == -15
def test_penalty_at_30pct_distance(self):
result = self._run_tt(30.0)
assert result["extended_penalty"] == -20
def test_penalty_floor_at_zero(self):
"""Penalty cannot make score negative (max(0, raw + penalty))."""
# Recent 50 at 80, older 200 at 120 → SMA50=80, SMA150≈107, SMA200≈110
# Price=105: above SMA50 by ~31% (penalty=-20) but below SMA150 (C1 fail)
# Only C4 passes → raw_score=14.3, 14.3+(-20)=-5.7 → floor to 0
n = 250
prices = []
for i in range(n):
close = 80.0 if i < 50 else 120.0
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": close,
"high": close * 1.005,
"low": close * 0.995,
"close": close,
"adjClose": close,
"volume": 1000000,
}
)
quote = {"price": 105.0, "yearHigh": 200.0, "yearLow": 100.0}
result = calculate_trend_template(prices, quote, rs_rank=10)
assert result["extended_penalty"] == -20
assert result["raw_score"] <= 14.3
assert result["score"] == 0
def test_price_below_sma50_no_penalty(self):
result = self._run_tt(-5.0)
assert result["extended_penalty"] == 0
# --- Boundary tests (R1-4) ---
def test_boundary_exactly_8pct(self):
result = self._run_tt(8.0)
assert result["extended_penalty"] == -5
def test_boundary_exactly_12pct(self):
result = self._run_tt(12.0)
assert result["extended_penalty"] == -10
def test_boundary_exactly_18pct(self):
result = self._run_tt(18.0)
assert result["extended_penalty"] == -15
def test_boundary_exactly_25pct(self):
result = self._run_tt(25.0)
assert result["extended_penalty"] == -20
# --- Gate separation (R1-1: most important) ---
def test_passed_uses_raw_score_not_adjusted(self):
"""raw >= 85, ext < 0 -> passed=True (raw >= 85), score < raw."""
# Build uptrending data (most-recent-first) so most criteria pass
n = 250
prices = []
for i in range(n):
# index 0 = newest (highest), index 249 = oldest (lowest)
base = 120 - 40 * i / (n - 1) # 120 → 80
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": base,
"high": base * 1.005,
"low": base * 0.995,
"close": base,
"adjClose": base,
"volume": 1000000,
}
)
# SMA50 ≈ avg of newest 50 prices (120 down to ~112)
sma50_approx = sum(p["close"] for p in prices[:50]) / 50
price = sma50_approx * 1.20 # 20% above SMA50
quote = {
"price": price,
"yearHigh": price * 1.02,
"yearLow": 60.0,
}
result = calculate_trend_template(prices, quote, rs_rank=85)
assert result["raw_score"] >= 85
assert result["passed"] is True
assert result["extended_penalty"] < 0
assert result["score"] < result["raw_score"]
def test_raw_score_in_result(self):
result = self._run_tt(10.0)
assert "raw_score" in result
def test_score_is_adjusted(self):
result = self._run_tt(15.0)
assert result["score"] == max(0, result["raw_score"] + result["extended_penalty"])
# --- Output fields ---
def test_sma50_distance_in_result(self):
result = self._run_tt(10.0)
assert "sma50_distance_pct" in result
assert result["sma50_distance_pct"] is not None
assert abs(result["sma50_distance_pct"] - 10.0) < 0.5
def test_extended_penalty_in_result(self):
result = self._run_tt(10.0)
assert "extended_penalty" in result
# --- Custom threshold (R1-3) ---
def test_custom_threshold_5pct(self):
result = self._run_tt(6.0, ext_threshold=5.0)
assert result["extended_penalty"] == -5
def test_custom_threshold_15pct(self):
result = self._run_tt(10.0, ext_threshold=15.0)
assert result["extended_penalty"] == 0
# ===========================================================================
# E2E Threshold Passthrough Test (R2-7)
# ===========================================================================
class TestExtThresholdE2E:
"""Test that ext_threshold passes through analyze_stock to trend_template."""
def test_ext_threshold_passes_through_to_trend_template(self):
"""analyze_stock(ext_threshold=15) uses 15% threshold for penalty."""
sma50_target = 100.0
n = 250
prices = []
for i in range(n):
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": sma50_target,
"high": sma50_target * 1.005,
"low": sma50_target * 0.995,
"close": sma50_target,
"adjClose": sma50_target,
"volume": 1000000,
}
)
# Price is 12% above SMA50
price = sma50_target * 1.12
quote = {
"price": price,
"yearHigh": price * 1.05,
"yearLow": sma50_target * 0.6,
}
sp500 = _make_prices(n, start=95, daily_change=0.0005)
# Default threshold=8 -> 12% distance -> penalty=-10
result_default = analyze_stock(
"TEST",
prices,
quote,
sp500,
"Tech",
"Test Corp",
)
tt_default = result_default["trend_template"]
assert tt_default["extended_penalty"] == -10
# Custom threshold=15 -> 12% distance -> no penalty
result_custom = analyze_stock(
"TEST",
prices,
quote,
sp500,
"Tech",
"Test Corp",
ext_threshold=15.0,
)
tt_custom = result_custom["trend_template"]
assert tt_custom["extended_penalty"] == 0
# ===========================================================================
# Sector Distribution Bug Fix Tests (Commit 1A)
# ===========================================================================
class TestSectorDistribution:
"""Test that sector distribution uses all_results, not just top N."""
def _make_stock(self, symbol, sector="Technology", score=75.0, rating=None):
if rating is None:
if score >= 90:
rating = "Textbook VCP"
elif score >= 80:
rating = "Strong VCP"
elif score >= 70:
rating = "Good VCP"
elif score >= 60:
rating = "Developing VCP"
elif score >= 50:
rating = "Weak VCP"
else:
rating = "No VCP"
return {
"symbol": symbol,
"company_name": f"{symbol} Corp",
"sector": sector,
"price": 150.0,
"market_cap": 50e9,
"composite_score": score,
"rating": rating,
"rating_description": "Test",
"guidance": "Test guidance",
"weakest_component": "Volume",
"weakest_score": 40,
"strongest_component": "Trend",
"strongest_score": 100,
"valid_vcp": True,
"entry_ready": False,
"trend_template": {"score": 100, "criteria_passed": 7},
"vcp_pattern": {
"score": 70,
"num_contractions": 2,
"contractions": [],
"pivot_price": 145.0,
},
"volume_pattern": {"score": 40, "dry_up_ratio": 0.8},
"pivot_proximity": {
"score": 75,
"distance_from_pivot_pct": -3.0,
"stop_loss_price": 140.0,
"risk_pct": 7.0,
"trade_status": "NEAR PIVOT",
},
"relative_strength": {"score": 80, "rs_rank_estimate": 80, "weighted_rs": 15.0},
}
def test_sector_distribution_uses_all_results(self):
"""Sector distribution should count all candidates, not just top N."""
all_results = [
self._make_stock("A1", "Technology"),
self._make_stock("A2", "Technology"),
self._make_stock("A3", "Healthcare"),
self._make_stock("A4", "Financials"),
self._make_stock("A5", "Financials"),
self._make_stock("A6", "Financials"),
]
top_results = all_results[:2] # Only Technology stocks
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {"vcp_candidates": 6},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report(top_results, metadata, md_file, all_results=all_results)
with open(md_file) as f:
content = f.read()
# Should contain Healthcare and Financials from all_results
assert "Healthcare" in content
assert "Financials" in content
def test_report_header_shows_top_count(self):
"""When top N < total, report should show 'Showing top X of Y'."""
all_results = [self._make_stock(f"S{i}") for i in range(10)]
top_results = all_results[:3]
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {"vcp_candidates": 10},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report(top_results, metadata, md_file, all_results=all_results)
with open(md_file) as f:
content = f.read()
assert "Showing top 3 of 10 candidates" in content
def test_no_top_count_when_all_shown(self):
"""When showing all results, no 'Showing top X of Y' message."""
results = [self._make_stock(f"S{i}") for i in range(5)]
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {"vcp_candidates": 5},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report(results, metadata, md_file, all_results=results)
with open(md_file) as f:
content = f.read()
assert "Showing top" not in content
def test_methodology_link_text(self):
"""Methodology link should not reference a nonexistent file path."""
results = [self._make_stock("S0")]
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report(results, metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "`references/vcp_methodology.md`" not in content
assert "VCP methodology reference" in content
def test_json_report_has_sector_distribution(self):
"""JSON report should include sector_distribution field."""
all_results = [
self._make_stock("A1", "Technology"),
self._make_stock("A2", "Healthcare"),
]
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
json_file = os.path.join(tmpdir, "test.json")
generate_json_report(all_results[:1], metadata, json_file, all_results=all_results)
with open(json_file) as f:
data = json.load(f)
assert "sector_distribution" in data
assert data["sector_distribution"]["Technology"] == 1
assert data["sector_distribution"]["Healthcare"] == 1
def test_section_headers_show_counts(self):
"""Section headers should show stock counts."""
entry_ready = self._make_stock("READY", score=85.0, rating="Strong VCP")
entry_ready["entry_ready"] = True
extended = self._make_stock("EXT", score=75.0, rating="Good VCP")
extended["entry_ready"] = False
results = [entry_ready, extended]
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report(results, metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "Pre-Breakout Watchlist (1 stock" in content
assert "Extended / Quality VCP (1 stock" in content
# ===========================================================================
# RS Percentile Ranking Tests (Commit 1D)
# ===========================================================================
class TestRSPercentileRanking:
"""Test universe-relative RS percentile ranking."""
def test_rank_ordering(self):
"""Higher weighted_rs gets higher percentile."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {
"AAPL": {"score": 80, "weighted_rs": 30.0},
"MSFT": {"score": 70, "weighted_rs": 20.0},
"GOOG": {"score": 60, "weighted_rs": 10.0},
"AMZN": {"score": 50, "weighted_rs": 5.0},
}
ranked = rank_relative_strength_universe(rs_map)
assert ranked["AAPL"]["rs_percentile"] > ranked["AMZN"]["rs_percentile"]
assert ranked["AAPL"]["score"] >= ranked["MSFT"]["score"]
def test_score_mapping(self):
"""Top percentile gets top score."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {f"S{i}": {"score": 50, "weighted_rs": float(i)} for i in range(100)}
ranked = rank_relative_strength_universe(rs_map)
# S99 has highest weighted_rs -> highest percentile -> highest score
assert ranked["S99"]["score"] >= 90
# S0 has lowest -> lowest score
assert ranked["S0"]["score"] <= 30
def test_single_stock(self):
"""Single stock capped by small-population rule (n=1 -> max score 70)."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {"ONLY": {"score": 50, "weighted_rs": 10.0}}
ranked = rank_relative_strength_universe(rs_map)
# With n=1, percentile and score are both capped
assert ranked["ONLY"]["score"] <= 70
assert ranked["ONLY"]["rs_percentile"] <= 74
def test_handles_none_weighted_rs(self):
"""Stocks with None weighted_rs get score=0 and percentile=0."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {
"GOOD": {"score": 80, "weighted_rs": 20.0},
"BAD": {"score": 0, "weighted_rs": None},
}
ranked = rank_relative_strength_universe(rs_map)
assert ranked["GOOD"]["rs_percentile"] > ranked["BAD"]["rs_percentile"]
assert ranked["BAD"]["score"] == 0
assert ranked["BAD"]["rs_percentile"] == 0
def test_empty_dict(self):
"""Empty input returns empty dict."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
ranked = rank_relative_strength_universe({})
assert ranked == {}
def test_tied_values(self):
"""Tied weighted_rs values should get same percentile."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {
"A": {"score": 50, "weighted_rs": 10.0},
"B": {"score": 50, "weighted_rs": 10.0},
"C": {"score": 50, "weighted_rs": 5.0},
}
ranked = rank_relative_strength_universe(rs_map)
assert ranked["A"]["rs_percentile"] == ranked["B"]["rs_percentile"]
assert ranked["A"]["rs_percentile"] > ranked["C"]["rs_percentile"]
def test_rs_percentile_in_report(self):
"""Report should show RS Percentile when available."""
stock = {
"symbol": "TEST",
"company_name": "Test Corp",
"sector": "Technology",
"price": 150.0,
"market_cap": 50e9,
"composite_score": 75.0,
"rating": "Good VCP",
"rating_description": "Test",
"guidance": "Test",
"weakest_component": "Volume",
"weakest_score": 40,
"strongest_component": "Trend",
"strongest_score": 100,
"valid_vcp": True,
"entry_ready": False,
"trend_template": {"score": 100, "criteria_passed": 7},
"vcp_pattern": {
"score": 70,
"num_contractions": 2,
"contractions": [],
"pivot_price": 145.0,
},
"volume_pattern": {"score": 40, "dry_up_ratio": 0.8},
"pivot_proximity": {
"score": 75,
"distance_from_pivot_pct": -3.0,
"stop_loss_price": 140.0,
"risk_pct": 7.0,
"trade_status": "NEAR PIVOT",
},
"relative_strength": {
"score": 85,
"rs_rank_estimate": 80,
"weighted_rs": 15.0,
"rs_percentile": 92,
},
}
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"funnel": {},
"api_stats": {},
}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "RS Percentile: 92" in content
# ===========================================================================
# ATR and ZigZag Swing Detection Tests (Commit 2)
# ===========================================================================
class TestATRCalculation:
"""Test _calculate_atr function."""
def test_atr_basic(self):
"""ATR for constant range bars should equal the range."""
from calculators.vcp_pattern_calculator import _calculate_atr
n = 20
highs = [105.0] * n
lows = [95.0] * n
closes = [100.0] * n
atr = _calculate_atr(highs, lows, closes, period=14)
assert abs(atr - 10.0) < 0.5
def test_atr_insufficient_data(self):
"""ATR with < period+1 data returns 0."""
from calculators.vcp_pattern_calculator import _calculate_atr
highs = [105.0] * 5
lows = [95.0] * 5
closes = [100.0] * 5
atr = _calculate_atr(highs, lows, closes, period=14)
assert atr == 0.0
class TestZigZagSwingDetection:
"""Test ATR-based ZigZag swing detection."""
def test_zigzag_finds_known_pattern(self):
"""A clear up-down-up-down pattern should find swing points."""
from calculators.vcp_pattern_calculator import _zigzag_swing_points
# Build: 20 bars up, 20 bars down, 20 bars up, 20 bars down
n = 80
highs, lows, closes, dates = [], [], [], []
for i in range(n):
if i < 20:
base = 100 + i * 2 # 100 -> 138
elif i < 40:
base = 138 - (i - 20) * 2 # 138 -> 100
elif i < 60:
base = 100 + (i - 40) * 2 # 100 -> 138
else:
base = 138 - (i - 60) * 2 # 138 -> 100
highs.append(base + 1)
lows.append(base - 1)
closes.append(float(base))
dates.append(f"day-{i}")
swing_highs, swing_lows = _zigzag_swing_points(
highs, lows, closes, dates, atr_multiplier=1.5
)
assert len(swing_highs) >= 1
assert len(swing_lows) >= 1
def test_smooth_uptrend_fewer_swings(self):
"""Smooth uptrend should produce fewer swings than choppy market."""
from calculators.vcp_pattern_calculator import _zigzag_swing_points
n = 100
# Smooth uptrend
smooth_highs = [100 + i * 0.5 + 1 for i in range(n)]
smooth_lows = [100 + i * 0.5 - 1 for i in range(n)]
smooth_closes = [100 + i * 0.5 for i in range(n)]
dates = [f"day-{i}" for i in range(n)]
sh, sl = _zigzag_swing_points(smooth_highs, smooth_lows, smooth_closes, dates)
# Should produce very few swings (smooth trend)
assert len(sh) + len(sl) <= 4
def test_atr_multiplier_sensitivity(self):
"""Higher multiplier = fewer swing points detected."""
from calculators.vcp_pattern_calculator import _zigzag_swing_points
n = 80
highs, lows, closes, dates = [], [], [], []
for i in range(n):
if i < 20:
base = 100 + i * 2
elif i < 40:
base = 138 - (i - 20) * 2
elif i < 60:
base = 100 + (i - 40) * 2
else:
base = 138 - (i - 60) * 2
highs.append(base + 1)
lows.append(base - 1)
closes.append(float(base))
dates.append(f"day-{i}")
sh_low, sl_low = _zigzag_swing_points(highs, lows, closes, dates, atr_multiplier=0.5)
sh_high, sl_high = _zigzag_swing_points(highs, lows, closes, dates, atr_multiplier=3.0)
# Lower multiplier should find at least as many swings
assert len(sh_low) + len(sl_low) >= len(sh_high) + len(sl_high)
def test_insufficient_data(self):
"""< 15 bars of data should return empty."""
from calculators.vcp_pattern_calculator import _zigzag_swing_points
highs = [105.0] * 10
lows = [95.0] * 10
closes = [100.0] * 10
dates = [f"day-{i}" for i in range(10)]
sh, sl = _zigzag_swing_points(highs, lows, closes, dates)
assert sh == []
assert sl == []
# ===========================================================================
# VCP Pattern Enhanced Tests (Commit 3: ZigZag integration)
# ===========================================================================
class TestVCPPatternEnhanced:
"""Test ZigZag integration, multi-start, and min contraction duration."""
def _make_vcp_prices(self, n=120):
"""Build synthetic VCP price data (most-recent-first) with clear contractions.
Creates: ramp up -> T1 drop -> recovery -> T2 smaller drop -> recovery
"""
# Chronological (oldest first): build then reverse
chrono = []
for i in range(n):
if i < 30:
# Ramp up from 80 to 120
base = 80 + (40 * i / 30)
elif i < 50:
# T1: drop from 120 to ~100 (16.7%)
progress = (i - 30) / 20
base = 120 - 20 * progress
elif i < 70:
# Recovery back to ~118
progress = (i - 50) / 20
base = 100 + 18 * progress
elif i < 85:
# T2: drop from 118 to ~110 (6.8%)
progress = (i - 70) / 15
base = 118 - 8 * progress
else:
# Recovery to ~117, consolidation near pivot
progress = (i - 85) / (n - 85)
base = 110 + 7 * progress
chrono.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": round(base, 2),
"high": round(base * 1.01, 2),
"low": round(base * 0.99, 2),
"close": round(base, 2),
"volume": 1000000,
}
)
# Return most-recent-first
return list(reversed(chrono))
def test_backward_compatible_return_schema(self):
"""calculate_vcp_pattern returns same keys as before."""
prices = self._make_vcp_prices()
result = calculate_vcp_pattern(prices)
assert "score" in result
assert "valid_vcp" in result
assert "contractions" in result
assert "num_contractions" in result
assert "pivot_price" in result
def test_new_params_accepted(self):
"""New parameters atr_multiplier, atr_period, min_contraction_days accepted."""
prices = self._make_vcp_prices()
result = calculate_vcp_pattern(
prices, atr_multiplier=2.0, atr_period=10, min_contraction_days=3
)
assert isinstance(result, dict)
assert "score" in result
def test_atr_value_in_result(self):
"""Result should include atr_value when ZigZag is used."""
prices = self._make_vcp_prices()
result = calculate_vcp_pattern(prices, atr_multiplier=1.5)
# atr_value may be present (if ZigZag was used) or absent (if fell back)
# Either way, the result should not crash
assert isinstance(result, dict)
def test_contraction_duration_in_result(self):
"""Contractions should have duration_days field when available."""
prices = self._make_vcp_prices()
result = calculate_vcp_pattern(prices, atr_multiplier=1.5, min_contraction_days=3)
for c in result.get("contractions", []):
assert "duration_days" in c
def test_existing_validation_still_works(self):
"""_validate_vcp should still work with existing test data."""
contractions = _make_vcp_contractions([20, 10, 5])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is True
def test_min_contraction_days_filters_short(self):
"""Contractions shorter than min_contraction_days should be excluded."""
from calculators.vcp_pattern_calculator import calculate_vcp_pattern
prices = self._make_vcp_prices()
# Very high min_contraction_days should reduce or eliminate contractions
result = calculate_vcp_pattern(prices, min_contraction_days=50)
# With 50-day minimum, most contractions would be filtered
assert result["num_contractions"] <= 2
# ===========================================================================
# Volume Zone Analysis Tests (Commit 4)
# ===========================================================================
class TestVolumeZoneAnalysis:
"""Test zone-based volume analysis."""
def _make_volume_prices(self, n=60, base_vol=1000000, dry_up_vol=300000):
"""Build prices with clear volume zones (most-recent-first)."""
prices = []
for i in range(n):
vol = base_vol
if i < 10: # Recent bars: dry-up zone
vol = dry_up_vol
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": 100.0,
"high": 101.0,
"low": 99.0,
"close": 100.0,
"volume": vol,
}
)
return prices
def test_backward_compatible_without_contractions(self):
"""Without contractions param, old behavior preserved."""
prices = self._make_volume_prices()
result = calculate_volume_pattern(prices, pivot_price=101.0)
assert "dry_up_ratio" in result
assert result["score"] > 0
def test_zone_analysis_present(self):
"""When contractions provided, zone_analysis should appear in result."""
prices = self._make_volume_prices()
contractions = [
{"high_idx": 30, "low_idx": 40, "label": "T1"},
{"high_idx": 20, "low_idx": 25, "label": "T2"},
]
result = calculate_volume_pattern(prices, pivot_price=101.0, contractions=contractions)
assert "zone_analysis" in result
def test_zone_b_dry_up(self):
"""Zone B (pivot approach) with low volume -> high dry-up score."""
# Build prices where bars near pivot have very low volume
prices = []
for i in range(60):
vol = 1000000
if i < 10: # Most recent: very dry
vol = 100000
prices.append(
{
"date": f"day-{i}",
"open": 100.0,
"high": 101.0,
"low": 99.0,
"close": 100.0,
"volume": vol,
}
)
contractions = [
{"high_idx": 30, "low_idx": 40, "label": "T1"},
{"high_idx": 15, "low_idx": 20, "label": "T2"},
]
result = calculate_volume_pattern(prices, pivot_price=101.0, contractions=contractions)
assert result["dry_up_ratio"] < 0.5
def test_contraction_volume_declining_bonus(self):
"""Declining volume across contractions should add bonus and report trend."""
# Build prices where T1 zone has higher volume than T2 zone
n = 60
prices = []
for i in range(n):
vol = 1000000
# T1: chronological 10-20, reversed = 39-49
if 39 <= i <= 49:
vol = 2000000
# T2: chronological 35-45, reversed = 14-24
elif 14 <= i <= 24:
vol = 500000
prices.append(
{
"date": f"day-{i}",
"open": 100.0,
"high": 101.0,
"low": 99.0,
"close": 100.0,
"volume": vol,
}
)
contractions = [
{"high_idx": 10, "low_idx": 20, "label": "T1"},
{"high_idx": 35, "low_idx": 45, "label": "T2"},
]
result = calculate_volume_pattern(prices, pivot_price=101.0, contractions=contractions)
assert "contraction_volume_trend" in result
assert result["contraction_volume_trend"]["declining"] is True
def test_empty_contractions_fallback(self):
"""Empty contractions list should use old behavior."""
prices = self._make_volume_prices()
result = calculate_volume_pattern(prices, pivot_price=101.0, contractions=[])
# Should still work with old logic
assert "dry_up_ratio" in result
assert result["score"] > 0
def test_breakout_volume_uses_zone_c(self):
"""When breakout bar is at pivot, zone C volume should be used."""
prices = []
for i in range(60):
vol = 500000
close = 100.0
if i == 0:
# Breakout bar: high volume, price above pivot
vol = 2000000
close = 102.0
prices.append(
{
"date": f"day-{i}",
"open": close - 1,
"high": close + 0.5,
"low": close - 1.5,
"close": close,
"volume": vol,
}
)
contractions = [
{"high_idx": 30, "low_idx": 40, "label": "T1"},
{"high_idx": 15, "low_idx": 20, "label": "T2"},
]
result = calculate_volume_pattern(prices, pivot_price=101.0, contractions=contractions)
assert result["breakout_volume_detected"] is True
# ===========================================================================
# Code Review Fix Tests: RS None handling, weakest/strongest update,
# small population, and tautological test fixes
# ===========================================================================
class TestRSNoneHandling:
"""Issue #1 (High): weighted_rs=None stocks must not inflate scores."""
def test_all_none_get_score_zero(self):
"""All-None universe: every stock should get score=0, not score=100."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {
"A": {"score": 0, "weighted_rs": None, "error": "No SPY data"},
"B": {"score": 0, "weighted_rs": None, "error": "No SPY data"},
"C": {"score": 0, "weighted_rs": None, "error": "No SPY data"},
}
ranked = rank_relative_strength_universe(rs_map)
for sym in ["A", "B", "C"]:
assert ranked[sym]["score"] == 0
assert ranked[sym]["rs_percentile"] == 0
def test_mixed_none_excludes_none_from_percentile(self):
"""None stocks excluded from percentile; valid stocks ranked among themselves."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {
"GOOD1": {"score": 80, "weighted_rs": 30.0},
"GOOD2": {"score": 70, "weighted_rs": 10.0},
"BAD": {"score": 0, "weighted_rs": None, "error": "No data"},
}
ranked = rank_relative_strength_universe(rs_map)
# BAD should get score=0
assert ranked["BAD"]["score"] == 0
assert ranked["BAD"]["rs_percentile"] == 0
# GOOD1 should still be ranked highest among valid stocks
assert ranked["GOOD1"]["rs_percentile"] > ranked["GOOD2"]["rs_percentile"]
assert ranked["GOOD1"]["score"] > 0
def test_none_stock_preserves_error(self):
"""None-weighted_rs stock should preserve its original error field."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {
"OK": {"score": 50, "weighted_rs": 10.0},
"ERR": {"score": 0, "weighted_rs": None, "error": "SPY fetch failed"},
}
ranked = rank_relative_strength_universe(rs_map)
assert ranked["ERR"]["error"] == "SPY fetch failed"
class TestRSSmallPopulation:
"""Issue #3 (Medium): small populations should cap percentile scores."""
def test_small_population_caps_score(self):
"""With fewer than 10 valid stocks, scores should be capped."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
# 3 stocks: highest should NOT get score=100
rs_map = {
"A": {"score": 50, "weighted_rs": 20.0},
"B": {"score": 50, "weighted_rs": 10.0},
"C": {"score": 50, "weighted_rs": 5.0},
}
ranked = rank_relative_strength_universe(rs_map)
# With only 3 valid stocks, best should be capped at 80
assert ranked["A"]["score"] <= 80
def test_small_population_caps_percentile_consistently(self):
"""rs_percentile must be capped consistently with score."""
from calculators.relative_strength_calculator import (
_percentile_to_score,
rank_relative_strength_universe,
)
# 3 stocks: raw percentile would be 100 for top stock
rs_map = {
"A": {"score": 50, "weighted_rs": 20.0},
"B": {"score": 50, "weighted_rs": 10.0},
"C": {"score": 50, "weighted_rs": 5.0},
}
ranked = rank_relative_strength_universe(rs_map)
# Percentile must produce the capped score when passed through _percentile_to_score
for sym in ["A", "B", "C"]:
pct = ranked[sym]["rs_percentile"]
score = ranked[sym]["score"]
assert _percentile_to_score(pct) == score
def test_large_population_no_cap(self):
"""With 20+ valid stocks, no cap is applied."""
from calculators.relative_strength_calculator import rank_relative_strength_universe
rs_map = {f"S{i}": {"score": 50, "weighted_rs": float(i)} for i in range(20)}
ranked = rank_relative_strength_universe(rs_map)
assert ranked["S19"]["score"] >= 90
# Percentile should also be uncapped
assert ranked["S19"]["rs_percentile"] >= 95
class TestWeakestStrongestUpdate:
"""Issue #2 (Medium): weakest/strongest must update after RS re-ranking."""
def test_weakest_strongest_reflects_updated_rs(self):
"""After RS re-ranking, composite result must have fresh weakest/strongest."""
# Simulate a result where RS was initially strongest (score=100)
# but after re-ranking becomes weaker (score=40)
composite = calculate_composite_score(
trend_score=80,
contraction_score=70,
volume_score=60,
pivot_score=50,
rs_score=40, # RS now weakest after re-ranking
)
assert composite["weakest_component"] == "Relative Strength"
assert composite["weakest_score"] == 40
# The strongest should be Trend Template
assert composite["strongest_component"] == "Trend Template (Stage 2)"
assert composite["strongest_score"] == 80
class TestFixedTautologicalTests:
"""Issue #4 (Low): fix tests that were always-true."""
def test_new_params_no_crash(self):
"""calculate_vcp_pattern with new params should not raise an exception."""
prices = TestVCPPatternEnhanced._make_vcp_prices(None)
result = calculate_vcp_pattern(
prices, atr_multiplier=2.0, atr_period=10, min_contraction_days=3
)
assert isinstance(result, dict)
assert "score" in result
def test_declining_volume_bonus_value(self):
"""Declining contraction volume should yield +5 bonus vs non-declining."""
# Build prices with declining volume in contraction zones
prices_declining = []
prices_flat = []
for i in range(60):
close = 100.0
vol_base = 1000000
prices_declining.append(
{
"date": f"day-{i}",
"open": 100.0,
"high": 101.0,
"low": 99.0,
"close": close,
"volume": vol_base,
}
)
prices_flat.append(
{
"date": f"day-{i}",
"open": 100.0,
"high": 101.0,
"low": 99.0,
"close": close,
"volume": vol_base,
}
)
# Contractions where T1 zone has higher volume than T2 zone
# (chronological indices: T1 is earlier, T2 is later)
n = 60
# T1: indices 10-20 (chronological), reversed = 39-49
# T2: indices 35-45 (chronological), reversed = 14-24
# For declining: T1 zone gets high volume, T2 zone gets low volume
for i in range(n):
n - 1 - i
# T1 chronological 10-20 -> reversed 39-49
if 39 <= i <= 49:
prices_declining[i]["volume"] = 2000000
prices_flat[i]["volume"] = 1000000
# T2 chronological 35-45 -> reversed 14-24
if 14 <= i <= 24:
prices_declining[i]["volume"] = 500000
prices_flat[i]["volume"] = 1000000
contractions = [
{"high_idx": 10, "low_idx": 20, "label": "T1"},
{"high_idx": 35, "low_idx": 45, "label": "T2"},
]
result_declining = calculate_volume_pattern(
prices_declining, pivot_price=101.0, contractions=contractions
)
result_flat = calculate_volume_pattern(
prices_flat, pivot_price=101.0, contractions=contractions
)
# Declining should have the bonus
assert result_declining.get("contraction_volume_trend", {}).get("declining") is True
assert result_flat.get("contraction_volume_trend", {}).get("declining") is False
# Score difference should be exactly 10 (strengthened bonus in Phase 4)
assert result_declining["score"] - result_flat["score"] == 10
# ===========================================================================
# Parameter Passthrough Tests (VCP tuning parameters)
# ===========================================================================
class TestParameterPassthrough:
"""Test that new tuning parameters correctly affect VCP detection."""
def test_min_contractions_3_rejects_2_contraction_pattern(self):
"""min_contractions=3 should reject a pattern with only 2 contractions."""
contractions = _make_vcp_contractions([20, 10])
result = _validate_vcp(contractions, total_days=120, min_contractions=3)
assert result["valid"] is False
assert any("3" in issue for issue in result["issues"])
def test_min_contractions_2_default_backward_compatible(self):
"""min_contractions=2 (default) accepts a 2-contraction pattern."""
contractions = _make_vcp_contractions([20, 10])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is True
def test_t1_depth_min_12_rejects_shallow(self):
"""t1_depth_min=12 should reject T1=10% pattern."""
contractions = _make_vcp_contractions([10, 5])
result = _validate_vcp(contractions, total_days=120, t1_depth_min=12.0)
assert result["valid"] is False
assert any("12.0" in issue for issue in result["issues"])
def test_t1_depth_min_default_accepts_8pct(self):
"""Default t1_depth_min=8.0 accepts T1=10%."""
contractions = _make_vcp_contractions([10, 5])
result = _validate_vcp(contractions, total_days=120)
assert result["valid"] is True
def test_contraction_ratio_09_accepts_looser(self):
"""contraction_ratio=0.9 accepts T1=20%, T2=17% (ratio=0.85)."""
contractions = _make_vcp_contractions([20, 17])
# Default 0.75 rejects
result_strict = _validate_vcp(contractions, total_days=120, contraction_ratio=0.75)
assert result_strict["valid"] is False
# Relaxed 0.9 accepts
result_relaxed = _validate_vcp(contractions, total_days=120, contraction_ratio=0.9)
assert result_relaxed["valid"] is True
def test_breakout_volume_ratio_2_rejects_16x(self):
"""breakout_volume_ratio=2.0 should not detect 1.6x as breakout."""
prices = _make_prices(60, volume=1000000)
# Most recent bar: 1.6x volume, price above pivot
prices[0]["volume"] = 1600000
prices[0]["close"] = 102.0
result = calculate_volume_pattern(prices, pivot_price=100.0, breakout_volume_ratio=2.0)
assert result["breakout_volume_detected"] is False
def test_breakout_volume_ratio_default_detects_16x(self):
"""Default breakout_volume_ratio=1.5 detects 1.6x as breakout."""
prices = _make_prices(60, volume=1000000)
prices[0]["volume"] = 1600000
prices[0]["close"] = 102.0
result = calculate_volume_pattern(prices, pivot_price=100.0)
assert result["breakout_volume_detected"] is True
def test_calculate_vcp_pattern_min_contractions_3(self):
"""calculate_vcp_pattern with min_contractions=3 finds fewer patterns."""
from calculators.vcp_pattern_calculator import calculate_vcp_pattern
prices = TestVCPPatternEnhanced._make_vcp_prices(None)
result_2 = calculate_vcp_pattern(prices, min_contractions=2)
result_3 = calculate_vcp_pattern(prices, min_contractions=3)
# With stricter min_contractions, either fewer contractions or not valid
if result_2["valid_vcp"] and result_2["num_contractions"] < 3:
assert result_3["valid_vcp"] is False
class TestTrendMinScore:
"""Test passes_trend_filter (Phase 2 gate) from screen_vcp.py."""
def test_trend_min_score_70_passes_raw_75(self):
"""passes_trend_filter with raw_score=75 and threshold=70 -> True."""
tt_result = {"raw_score": 75, "passed": False}
assert passes_trend_filter(tt_result, trend_min_score=70) is True
def test_trend_min_score_85_rejects_raw_80(self):
"""passes_trend_filter with raw_score=80 and default threshold=85 -> False."""
tt_result = {"raw_score": 80, "passed": False}
assert passes_trend_filter(tt_result) is False
def test_trend_min_score_100_rejects_all(self):
"""passes_trend_filter with threshold=100 rejects 99.9."""
tt_result = {"raw_score": 99.9, "passed": True}
assert passes_trend_filter(tt_result, trend_min_score=100) is False
def test_uses_raw_score_not_passed_field(self):
"""Phase 2 must gate on raw_score, not the passed boolean.
A stock with raw_score=75 and passed=False should still pass
Phase 2 when trend_min_score=70.
"""
tt_result = {"raw_score": 75, "passed": False, "score": 60}
assert passes_trend_filter(tt_result, trend_min_score=70) is True
# Verify it would NOT pass if we used the 'passed' field
assert tt_result["passed"] is False
def test_missing_raw_score_returns_false(self):
"""If raw_score key is absent, passes_trend_filter defaults to 0."""
tt_result = {"passed": True, "score": 85}
assert passes_trend_filter(tt_result, trend_min_score=85) is False
def test_with_real_calculate_trend_template(self):
"""Integration: real calculate_trend_template output flows through filter."""
prices = _make_prices(250, start=80, daily_change=0.001)
quote = {"price": 120, "yearHigh": 125, "yearLow": 70}
tt_result = calculate_trend_template(prices, quote, rs_rank=85)
raw = tt_result.get("raw_score", 0)
# The result must be consistent with passes_trend_filter
assert passes_trend_filter(tt_result, trend_min_score=raw) is True
assert passes_trend_filter(tt_result, trend_min_score=raw + 0.1) is False
class TestBacktestRegression:
"""Regression tests ensuring stricter params are more selective."""
def test_min_contractions_3_more_selective_than_2(self):
"""min_contractions=3 should be at least as selective as =2."""
contractions_2 = _make_vcp_contractions([20, 10])
result_2 = _validate_vcp(contractions_2, total_days=120, min_contractions=2)
result_3 = _validate_vcp(contractions_2, total_days=120, min_contractions=3)
# 2-contraction pattern: valid for min=2, invalid for min=3
assert result_2["valid"] is True
assert result_3["valid"] is False
def test_higher_t1_depth_min_excludes_shallow(self):
"""Higher t1_depth_min excludes patterns that default accepts."""
contractions = _make_vcp_contractions([10, 5])
result_default = _validate_vcp(contractions, total_days=120, t1_depth_min=8.0)
result_strict = _validate_vcp(contractions, total_days=120, t1_depth_min=15.0)
assert result_default["valid"] is True
assert result_strict["valid"] is False
class TestTuningParamsMetadata:
"""Test that tuning_params appear in report metadata."""
def test_metadata_tuning_params_from_parse_arguments(self):
"""parse_arguments() produces args with all 8 tuning param attributes.
This exercises the real CLI parser so a renamed or missing flag
causes a test failure.
"""
import sys
from unittest.mock import patch
test_argv = [
"screen_vcp.py",
"--min-contractions",
"3",
"--t1-depth-min",
"12.0",
"--breakout-volume-ratio",
"2.0",
"--trend-min-score",
"90.0",
"--atr-multiplier",
"2.0",
"--contraction-ratio",
"0.6",
"--min-contraction-days",
"7",
"--lookback-days",
"180",
]
with patch.object(sys, "argv", test_argv):
args = parse_arguments()
# Build tuning_params the same way screen_vcp.main() does (line ~620)
tuning_params = {
"min_contractions": args.min_contractions,
"t1_depth_min": args.t1_depth_min,
"breakout_volume_ratio": args.breakout_volume_ratio,
"trend_min_score": args.trend_min_score,
"atr_multiplier": args.atr_multiplier,
"contraction_ratio": args.contraction_ratio,
"min_contraction_days": args.min_contraction_days,
"lookback_days": args.lookback_days,
}
assert len(tuning_params) == 8
assert tuning_params["min_contractions"] == 3
assert tuning_params["trend_min_score"] == 90.0
assert tuning_params["lookback_days"] == 180
def test_tuning_params_in_json_report(self):
"""JSON report should include tuning_params in metadata."""
metadata = {
"generated_at": "2026-01-01",
"universe_description": "Test",
"tuning_params": {
"min_contractions": 2,
"t1_depth_min": 8.0,
"breakout_volume_ratio": 1.5,
"trend_min_score": 85.0,
"atr_multiplier": 1.5,
"contraction_ratio": 0.75,
"min_contraction_days": 5,
"lookback_days": 120,
},
"funnel": {},
"api_stats": {},
}
stock = {
"symbol": "TEST",
"company_name": "Test Corp",
"sector": "Technology",
"price": 150.0,
"market_cap": 50e9,
"composite_score": 75.0,
"rating": "Good VCP",
"rating_description": "Test",
"guidance": "Test",
"weakest_component": "Volume",
"weakest_score": 40,
"strongest_component": "Trend",
"strongest_score": 100,
"valid_vcp": True,
"entry_ready": False,
"trend_template": {"score": 100, "criteria_passed": 7},
"vcp_pattern": {
"score": 70,
"num_contractions": 2,
"contractions": [],
"pivot_price": 145.0,
},
"volume_pattern": {"score": 40, "dry_up_ratio": 0.8},
"pivot_proximity": {
"score": 75,
"distance_from_pivot_pct": -3.0,
"stop_loss_price": 140.0,
"risk_pct": 7.0,
"trade_status": "NEAR PIVOT",
},
"relative_strength": {"score": 80, "rs_rank_estimate": 80, "weighted_rs": 15.0},
}
with tempfile.TemporaryDirectory() as tmpdir:
json_file = os.path.join(tmpdir, "test.json")
generate_json_report([stock], metadata, json_file)
with open(json_file) as f:
data = json.load(f)
assert "tuning_params" in data["metadata"]
tp = data["metadata"]["tuning_params"]
assert len(tp) == 8
assert tp["min_contractions"] == 2
assert tp["trend_min_score"] == 85.0
# ---------------------------------------------------------------------------
# Review fix regression tests
# ---------------------------------------------------------------------------
def test_entry_ready_below_stop_returns_false():
"""#1: Price below stop level should never be entry_ready."""
result = {
"valid_vcp": True,
"distance_from_pivot_pct": -5.5, # within -8 to +3 window
"volume_pattern": {"dry_up_ratio": 0.5},
"pivot_proximity": {
"risk_pct": None, # None because price < stop (setup invalidated)
"trade_status": "BELOW STOP LEVEL",
"stop_loss_price": 95.04,
},
}
assert compute_entry_ready(result) is False
def test_accumulation_requires_above_avg_volume():
"""#2: Up-close days with below-average volume should not count as accumulation."""
# Build 50 days of data: alternating up/down closes, but volume always below avg
prices = []
avg_vol = 1_000_000
for i in range(50):
# Alternating closes: even=102, odd=100 (up day when i even, most-recent-first)
close = 102.0 if i % 2 == 0 else 100.0
prices.append(
{
"date": f"2025-01-{i + 1:02d}",
"open": 100.0,
"high": 103.0,
"low": 99.0,
"close": close,
"volume": avg_vol // 2, # always below average
}
)
result = calculate_volume_pattern(prices)
# With all volumes below average, no day should count as accumulation or distribution
assert result["up_volume_days_20d"] == 0
assert result["down_volume_days_20d"] == 0
assert result["net_accumulation"] == 0
def test_multi_start_prefers_valid_over_longer():
"""#3: 2-contraction valid pattern should beat 3-contraction invalid."""
# We test the selection logic by constructing price data where:
# - The highest swing high leads to 3 contractions but fails contraction_ratio
# - A lower swing high leads to 2 valid contractions with good score
# Build chronological data with known swing pattern
n = 100
prices_chrono = []
for i in range(n):
# Base uptrend
base = 100 + i * 0.3
prices_chrono.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": base,
"high": base + 2,
"low": base - 2,
"close": base,
"volume": 1_000_000,
}
)
# The VCP calculator reverses to chronological internally, so we provide
# most-recent-first (standard format)
historical = list(reversed(prices_chrono))
result = calculate_vcp_pattern(historical, lookback_days=100, min_contractions=2)
# The key assertion: if a valid pattern exists, valid_vcp should be True
# (i.e., multi-start should not prefer longer-but-invalid over shorter-but-valid)
# This is a structural test - the old code could pick longer invalid over shorter valid.
# With the fix, `valid` flag is prioritized over length.
assert result is not None
assert "valid_vcp" in result
def test_prefilter_score_capped_at_100():
"""#5: Pre-filter score should not exceed 100 even for extreme pct_above_low."""
quote = {
"price": 300.0,
"yearLow": 100.0, # 200% above low
"yearHigh": 310.0, # within 30% of high
"avgVolume": 500000,
}
passed, score = pre_filter_stock(quote)
assert passed is True
# pct_above_low = 2.0, capped at 1.0 → 50 + (1 - 0.032) * 50 ≈ 98.4
assert score <= 100
def test_prefilter_stable_quote_volume_fallback():
"""Regression: /stable quote has no `avgVolume`; fall back to `volume`.
Without the fallback the < 200k liquidity check rejected the entire
universe (every avg_volume read as 0).
"""
# /stable shape: no avgVolume, liquid `volume` -> must pass.
stable_liquid = {"price": 300.0, "yearLow": 100.0, "yearHigh": 310.0, "volume": 5_000_000}
passed, _ = pre_filter_stock(stable_liquid)
assert passed is True
# /stable shape with illiquid session volume -> correctly rejected.
stable_illiquid = {"price": 300.0, "yearLow": 100.0, "yearHigh": 310.0, "volume": 50_000}
passed, _ = pre_filter_stock(stable_illiquid)
assert passed is False
# v3 `avgVolume` still takes priority when present.
v3_quote = {
"price": 300.0,
"yearLow": 100.0,
"yearHigh": 310.0,
"avgVolume": 500_000,
"volume": 10,
}
passed, _ = pre_filter_stock(v3_quote)
assert passed is True
def test_below_stop_report_shows_stop_violated():
"""BELOW STOP LEVEL should show 'STOP VIOLATED' in report, not buy guidance."""
stock = {
"symbol": "TEST",
"company_name": "Test Corp",
"sector": "Technology",
"price": 94.0,
"market_cap": 10e9,
"composite_score": 45.0,
"rating": "Weak VCP",
"rating_description": "Weak",
"guidance": "Buy at pivot, standard position sizing",
"valid_vcp": True,
"distance_from_pivot_pct": -6.0,
"weakest_component": "pivot",
"weakest_score": 0,
"strongest_component": "trend",
"strongest_score": 95,
"entry_ready": False,
"trend_template": {"score": 95, "criteria_passed": 7},
"vcp_pattern": {
"score": 70,
"num_contractions": 2,
"contractions": [],
"pivot_price": 100.0,
},
"volume_pattern": {"score": 60, "dry_up_ratio": 0.5},
"pivot_proximity": {
"score": 0,
"distance_from_pivot_pct": -6.0,
"stop_loss_price": 95.04,
"risk_pct": None,
"trade_status": "BELOW STOP LEVEL",
},
"relative_strength": {"score": 80, "rs_rank_estimate": 80, "weighted_rs": 10.0},
}
metadata = {"generated_at": "2025-01-01", "funnel": {}}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "STOP VIOLATED" in content
assert "setup invalidated" in content.lower()
# Should NOT contain normal buy guidance
assert "Buy at pivot, standard position sizing" not in content
def test_build_contractions_does_not_skip_intermediate_high():
"""Contraction builder must not jump past intermediate swing highs."""
from calculators.vcp_pattern_calculator import _build_contractions_from
# swing_highs: H0=100@idx0, H1=98@idx4, H2=97@idx10
# swing_lows: L0=90@idx2, L1=92@idx7, L2=93@idx12
# With min_contraction_days=5:
# From H0(idx=0), first low is L0(idx=2) -> duration=2 < 5, too short.
# Next candidate low is L1(idx=7) -> duration=7 >= 5.
# But H1(idx=4) is between H0 and L1, so we must NOT create a H0->L1 contraction.
swing_highs = [(0, 100.0), (4, 98.0), (10, 97.0)]
swing_lows = [(2, 90.0), (7, 92.0), (12, 93.0)]
highs = [100.0] * 15
lows = [90.0] * 15
dates = [f"day-{i}" for i in range(15)]
contractions = _build_contractions_from(
start_high=(0, 100.0),
swing_highs=swing_highs,
swing_lows=swing_lows,
highs=highs,
lows=lows,
dates=dates,
min_contraction_days=5,
)
# The builder should NOT produce a contraction from idx=0 to idx=7
# because idx=4 has an intermediate swing high
for c in contractions:
if c["high_idx"] == 0:
# If a contraction starts at idx 0, its low must not be past idx 4
assert c["low_idx"] <= 4, (
f"Contraction from idx=0 jumped to low_idx={c['low_idx']}, "
f"skipping intermediate swing high at idx=4"
)
# ---------------------------------------------------------------------------
# Phase 1: Execution State, Pattern Classifier, Scorer State Caps
# ---------------------------------------------------------------------------
from calculators.execution_state import apply_state_cap, compute_execution_state
from calculators.pattern_classifier import classify_pattern
class TestExecutionState:
"""Tests for compute_execution_state() — 10-rule decision tree."""
def test_invalid_price_below_sma50_below_sma200(self):
result = compute_execution_state(
distance_from_pivot_pct=None,
price=80.0,
sma50=90.0,
sma200=95.0,
sma200_distance_pct=-15.8,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Invalid"
assert result["reasons"]
def test_not_invalid_when_sma50_above_sma200(self):
# price < sma50, but sma50 > sma200 → Stage 2 possible, use Damaged not Invalid
result = compute_execution_state(
distance_from_pivot_pct=-2.0,
price=95.0,
sma50=100.0,
sma200=90.0,
sma200_distance_pct=5.6,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Damaged" # Rule 3, not Rule 1
def test_damaged_price_below_contraction_low(self):
result = compute_execution_state(
distance_from_pivot_pct=None,
price=88.0,
sma50=92.0,
sma200=85.0,
sma200_distance_pct=3.5,
last_contraction_low=90.0,
breakout_volume=False,
)
assert result["state"] == "Damaged"
assert "contraction low" in result["reasons"][0].lower()
def test_damaged_price_below_sma50(self):
result = compute_execution_state(
distance_from_pivot_pct=1.0,
price=95.0,
sma50=100.0,
sma200=90.0,
sma200_distance_pct=5.6,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Damaged"
def test_overextended_sma200_too_far(self):
result = compute_execution_state(
distance_from_pivot_pct=2.0,
price=200.0,
sma50=180.0,
sma200=120.0,
sma200_distance_pct=66.7,
last_contraction_low=None,
breakout_volume=False,
max_sma200_extension=50.0,
)
assert result["state"] == "Overextended"
def test_overextended_pivot_more_than_10pct(self):
result = compute_execution_state(
distance_from_pivot_pct=12.0,
price=112.0,
sma50=100.0,
sma200=90.0,
sma200_distance_pct=24.4,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Overextended"
def test_extended_5_to_10pct_above_pivot(self):
result = compute_execution_state(
distance_from_pivot_pct=7.5,
price=107.5,
sma50=100.0,
sma200=90.0,
sma200_distance_pct=19.4,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Extended"
def test_early_post_breakout_3_to_5pct(self):
result = compute_execution_state(
distance_from_pivot_pct=4.0,
price=104.0,
sma50=100.0,
sma200=90.0,
sma200_distance_pct=15.6,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Early-post-breakout"
def test_breakout_within_3pct_with_volume(self):
result = compute_execution_state(
distance_from_pivot_pct=1.5,
price=101.5,
sma50=98.0,
sma200=90.0,
sma200_distance_pct=12.8,
last_contraction_low=None,
breakout_volume=True,
)
assert result["state"] == "Breakout"
def test_pre_breakout_within_3pct_no_volume(self):
result = compute_execution_state(
distance_from_pivot_pct=1.5,
price=101.5,
sma50=98.0,
sma200=90.0,
sma200_distance_pct=12.8,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Early-post-breakout"
def test_pre_breakout_below_pivot(self):
# price above sma50 but below pivot — still forming pattern
result = compute_execution_state(
distance_from_pivot_pct=-3.0,
price=97.0,
sma50=94.0, # price > sma50, so no Damaged state
sma200=85.0,
sma200_distance_pct=14.1,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Pre-breakout"
assert "-3.0% below pivot" in result["reasons"][0]
class TestApplyStateCap:
"""Tests for apply_state_cap() helper."""
def test_invalid_caps_to_no_vcp(self):
capped, applied = apply_state_cap("Textbook VCP", "Invalid")
assert capped == "No VCP"
assert applied is True
def test_damaged_caps_strong_to_no_vcp(self):
capped, applied = apply_state_cap("Strong VCP", "Damaged")
assert capped == "No VCP"
assert applied is True
def test_overextended_caps_to_weak(self):
capped, applied = apply_state_cap("Good VCP", "Overextended")
assert capped == "Weak VCP"
assert applied is True
def test_extended_caps_to_developing(self):
capped, applied = apply_state_cap("Strong VCP", "Extended")
assert capped == "Developing VCP"
assert applied is True
def test_pre_breakout_no_cap(self):
capped, applied = apply_state_cap("Textbook VCP", "Pre-breakout")
assert capped == "Textbook VCP"
assert applied is False
def test_breakout_no_cap(self):
capped, applied = apply_state_cap("Strong VCP", "Breakout")
assert capped == "Strong VCP"
assert applied is False
def test_cap_does_not_upgrade(self):
# Weak VCP should not be upgraded by Overextended cap (max = Developing)
capped, applied = apply_state_cap("Weak VCP", "Overextended")
assert capped == "Weak VCP"
assert applied is False
class TestPatternClassifier:
"""Tests for classify_pattern()."""
def test_invalid_state_returns_damaged(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=3,
final_contraction_depth=8.0,
execution_state="Invalid",
dry_up_ratio=0.6,
)
assert result == "Damaged"
def test_damaged_state_returns_damaged(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=3,
final_contraction_depth=8.0,
execution_state="Damaged",
dry_up_ratio=0.6,
)
assert result == "Damaged"
def test_overextended_valid_vcp_returns_extended_leader(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=3,
final_contraction_depth=8.0,
execution_state="Overextended",
dry_up_ratio=0.6,
)
assert result == "Extended Leader"
def test_overextended_invalid_vcp_returns_vcp_adjacent(self):
result = classify_pattern(
valid_vcp=False,
num_contractions=2,
final_contraction_depth=20.0,
execution_state="Overextended",
dry_up_ratio=0.9,
)
assert result == "VCP-adjacent"
def test_breakout_valid_vcp_returns_post_breakout(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=3,
final_contraction_depth=8.0,
execution_state="Breakout",
dry_up_ratio=0.6,
)
assert result == "Post-breakout"
def test_early_post_breakout_returns_post_breakout(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=3,
final_contraction_depth=7.0,
execution_state="Early-post-breakout",
dry_up_ratio=0.65,
)
assert result == "Post-breakout"
def test_textbook_all_criteria_met(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=3,
final_contraction_depth=9.0,
execution_state="Pre-breakout",
dry_up_ratio=0.65,
wide_and_loose=False,
)
assert result == "Textbook VCP"
def test_textbook_blocked_by_wide_and_loose(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=3,
final_contraction_depth=9.0,
execution_state="Pre-breakout",
dry_up_ratio=0.65,
wide_and_loose=True,
)
assert result == "VCP-adjacent"
def test_not_textbook_only_2_contractions(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=2,
final_contraction_depth=9.0,
execution_state="Pre-breakout",
dry_up_ratio=0.65,
)
assert result == "VCP-adjacent"
def test_not_textbook_high_dry_up_ratio(self):
result = classify_pattern(
valid_vcp=True,
num_contractions=3,
final_contraction_depth=9.0,
execution_state="Pre-breakout",
dry_up_ratio=0.85, # > 0.7 threshold
)
assert result == "VCP-adjacent"
def test_invalid_vcp_always_vcp_adjacent(self):
result = classify_pattern(
valid_vcp=False,
num_contractions=3,
final_contraction_depth=9.0,
execution_state="Pre-breakout",
dry_up_ratio=0.5,
)
assert result == "VCP-adjacent"
class TestScorerStateCaps:
"""Tests for calculate_composite_score() with execution_state caps."""
def _base_scores(self):
return dict(
trend_score=90.0,
contraction_score=90.0,
volume_score=90.0,
pivot_score=90.0,
rs_score=90.0,
valid_vcp=True,
)
def test_no_cap_when_no_execution_state(self):
result = calculate_composite_score(**self._base_scores())
assert result["rating"] == "Textbook VCP"
assert result["state_cap_applied"] is False
def test_invalid_state_caps_to_no_vcp(self):
result = calculate_composite_score(**self._base_scores(), execution_state="Invalid")
assert result["rating"] == "No VCP"
assert result["state_cap_applied"] is True
def test_damaged_state_caps_to_no_vcp(self):
result = calculate_composite_score(**self._base_scores(), execution_state="Damaged")
assert result["rating"] == "No VCP"
assert result["state_cap_applied"] is True
def test_overextended_caps_textbook_to_weak(self):
result = calculate_composite_score(**self._base_scores(), execution_state="Overextended")
assert result["rating"] == "Weak VCP"
assert result["state_cap_applied"] is True
def test_extended_caps_strong_to_developing(self):
result = calculate_composite_score(**self._base_scores(), execution_state="Extended")
assert result["rating"] == "Developing VCP"
assert result["state_cap_applied"] is True
def test_pre_breakout_no_cap(self):
result = calculate_composite_score(**self._base_scores(), execution_state="Pre-breakout")
assert result["rating"] == "Textbook VCP"
assert result["state_cap_applied"] is False
def test_wide_and_loose_blocks_textbook(self):
result = calculate_composite_score(
**self._base_scores(),
execution_state="Pre-breakout",
wide_and_loose=True,
)
assert result["rating"] == "Developing VCP"
assert result["state_cap_applied"] is True
assert "Wide-and-loose" in result["cap_reason"]
def test_cap_does_not_upgrade_weak_rating(self):
# Score of 55 → Weak VCP; Extended caps at Developing VCP — no downgrade needed
result = calculate_composite_score(
trend_score=55.0,
contraction_score=55.0,
volume_score=55.0,
pivot_score=55.0,
rs_score=55.0,
valid_vcp=True,
execution_state="Extended",
)
assert result["rating"] == "Weak VCP"
assert result["state_cap_applied"] is False # Weak VCP == cap, no downgrade needed
def test_execution_state_stored_in_result(self):
result = calculate_composite_score(
**self._base_scores(),
execution_state="Pre-breakout",
pattern_type="Textbook VCP",
)
assert result["execution_state"] == "Pre-breakout"
assert result["pattern_type"] == "Textbook VCP"
class TestAnalyzeStockNewFields:
"""Verify analyze_stock() returns Phase 1 fields."""
def test_analyze_stock_has_execution_state_fields(self):
"""analyze_stock() result must contain execution_state and pattern_type."""
prices = _make_prices(250, start=100.0, daily_change=0.001, volume=1_000_000)
sp500 = _make_prices(250, start=400.0, daily_change=0.0005)
quote = {
"price": prices[0]["close"],
"marketCap": 10_000_000_000,
"yearHigh": max(p["high"] for p in prices),
"yearLow": min(p["low"] for p in prices),
}
result = analyze_stock(
symbol="TEST",
historical=prices,
quote=quote,
sp500_history=sp500,
)
assert result is not None
assert "execution_state" in result
assert "execution_state_reasons" in result
assert "pattern_type" in result
assert "state_cap_applied" in result
assert isinstance(result["execution_state_reasons"], list)
def test_analyze_stock_state_cap_applied_when_invalid(self):
"""A declining stock (price < SMA50 < SMA200) should have state_cap_applied."""
# Declining prices → price will be well below SMA50 and SMA200
prices = _make_prices(250, start=200.0, daily_change=-0.002, volume=1_000_000)
sp500 = _make_prices(250, start=400.0, daily_change=0.0005)
quote = {
"price": prices[0]["close"],
"marketCap": 5_000_000_000,
"yearHigh": prices[-1]["high"], # 52w high is oldest = lowest in decline
"yearLow": prices[0]["low"],
}
result = analyze_stock(
symbol="DECLINING",
historical=prices,
quote=quote,
sp500_history=sp500,
)
assert result is not None
# Declining stock should be capped (Invalid or Damaged state → No VCP)
if result["state_cap_applied"]:
assert result["rating"] in ("No VCP", "Developing VCP", "Weak VCP")
# ---------------------------------------------------------------------------
# Phase 2: SMA200 Penalty in Trend Template
# ---------------------------------------------------------------------------
from calculators.trend_template_calculator import (
_calculate_sma200_penalty,
)
class TestSMA200Penalty:
"""Tests for _calculate_sma200_penalty() and its integration."""
def test_no_penalty_below_threshold(self):
penalty, dist = _calculate_sma200_penalty(price=140.0, sma200=100.0, max_extension=50.0)
assert penalty == 0
assert dist == pytest.approx(40.0)
def test_no_penalty_at_exact_threshold(self):
penalty, dist = _calculate_sma200_penalty(price=150.0, sma200=100.0, max_extension=50.0)
assert penalty == 0
assert dist == pytest.approx(50.0)
def test_first_tier_penalty_just_above_threshold(self):
# 51% above SMA200, threshold=50 → excess=1 → tier −10
penalty, dist = _calculate_sma200_penalty(price=151.0, sma200=100.0, max_extension=50.0)
assert penalty == -10
assert dist == pytest.approx(51.0)
def test_second_tier_penalty_10pct_excess(self):
# 60% above SMA200, threshold=50 → excess=10 → tier −15
penalty, dist = _calculate_sma200_penalty(price=160.0, sma200=100.0, max_extension=50.0)
assert penalty == -15
def test_third_tier_penalty_20pct_excess(self):
# 70% above SMA200, threshold=50 → excess=20 → tier −20
penalty, dist = _calculate_sma200_penalty(price=170.0, sma200=100.0, max_extension=50.0)
assert penalty == -20
def test_no_penalty_when_sma200_none(self):
penalty, dist = _calculate_sma200_penalty(price=200.0, sma200=None)
assert penalty == 0
assert dist is None
def test_no_penalty_when_sma200_zero(self):
penalty, dist = _calculate_sma200_penalty(price=200.0, sma200=0.0)
assert penalty == 0
assert dist is None
def test_custom_max_extension(self):
# max_extension=30 → excess=5 at 35% above → tier −10
penalty, dist = _calculate_sma200_penalty(price=135.0, sma200=100.0, max_extension=30.0)
assert penalty == -10
def test_trend_template_returns_sma200_penalty_field(self):
prices = _make_prices(250, start=100.0, daily_change=0.001)
quote = {
"price": prices[0]["close"],
"yearHigh": max(p["high"] for p in prices),
"yearLow": min(p["low"] for p in prices),
}
result = calculate_trend_template(prices, quote)
assert "sma200_penalty" in result
assert "sma200_distance_pct" in result
def test_trend_template_penalty_applied_for_very_extended_stock(self):
"""A stock +80% above SMA200 should receive a penalty in trend score."""
# Build prices where recent bars are much higher than 200d avg
# Start low, drift up sharply for last 50 bars
base = _make_prices(200, start=50.0, daily_change=0.0)
recent = _make_prices(50, start=90.0, daily_change=0.001)
prices = recent + base # most-recent-first
quote = {
"price": prices[0]["close"],
"yearHigh": prices[0]["high"] * 1.01,
"yearLow": prices[-1]["low"] * 0.9,
}
result = calculate_trend_template(prices, quote, max_sma200_extension=50.0)
# sma200_distance_pct should reflect the extension
if result.get("sma200_distance_pct") is not None:
dist = result["sma200_distance_pct"]
if dist > 50.0:
assert result["sma200_penalty"] < 0
# SMA200 penalty is metadata only (not applied to score)
# to avoid double-penalizing with execution_state caps
assert result["sma200_distance_pct"] > 50.0
# ---------------------------------------------------------------------------
# Phase 3: ATR Compression, Wide-and-Loose, Right-side Tightness
# ---------------------------------------------------------------------------
class TestATRCompressionRatio:
"""Tests for atr_compression_ratio field in calculate_vcp_pattern()."""
def test_atr_compression_ratio_in_result(self):
prices = _make_prices(150, start=100.0, daily_change=0.001, volume=1_000_000)
result = calculate_vcp_pattern(prices)
assert "atr_compression_ratio" in result
def test_atr_compression_ratio_none_for_insufficient_data(self):
"""Short price history cannot compute ATR(50) → ratio is None."""
prices = _make_prices(40, start=100.0, daily_change=0.001)
result = calculate_vcp_pattern(prices)
# Either None or a valid float; insufficient data returns None
# (40 bars is not enough for ATR(50))
if result.get("atr_compression_ratio") is not None:
assert isinstance(result["atr_compression_ratio"], float)
def test_atr_compression_ratio_less_than_1_when_volatility_contracts(self):
"""Flat recent bars after volatile earlier bars → ratio < 1."""
# Volatile base phase followed by quiet consolidation
volatile = _make_prices(80, start=100.0, daily_change=0.005)
# Make volatile bars actually wide by inflating high/low spread
for p in volatile:
p["high"] = p["close"] * 1.03
p["low"] = p["close"] * 0.97
quiet = _make_prices(70, start=volatile[-1]["close"], daily_change=0.0)
for p in quiet:
p["high"] = p["close"] * 1.002
p["low"] = p["close"] * 0.998
prices = quiet + volatile # most-recent-first = quiet first
result = calculate_vcp_pattern(prices)
if result.get("atr_compression_ratio") is not None:
assert result["atr_compression_ratio"] < 1.0
def test_atr_compression_ratio_is_positive(self):
prices = _make_prices(150, start=100.0, daily_change=0.001)
result = calculate_vcp_pattern(prices)
if result.get("atr_compression_ratio") is not None:
assert result["atr_compression_ratio"] > 0
class TestWideAndLoose:
"""Tests for wide_and_loose flag in calculate_vcp_pattern() and _compute_wide_and_loose()."""
def test_wide_and_loose_false_by_default(self):
prices = _make_prices(150, start=100.0, daily_change=0.001)
result = calculate_vcp_pattern(prices)
assert "wide_and_loose" in result
assert isinstance(result["wide_and_loose"], bool)
def test_wide_and_loose_false_no_contractions(self):
prices = _make_prices(35, start=100.0, daily_change=0.0)
result = calculate_vcp_pattern(prices, min_contractions=2)
assert result["wide_and_loose"] is False
def test_wide_and_loose_threshold_parameter_accepted(self):
prices = _make_prices(150, start=100.0, daily_change=0.001)
result = calculate_vcp_pattern(prices, wide_and_loose_threshold=20.0)
assert "wide_and_loose" in result
# -----------------------------------------------------------------------
# Direct unit tests of _compute_wide_and_loose() helper
# -----------------------------------------------------------------------
def test_compute_wide_and_loose_true_deep_and_short(self):
from calculators.vcp_pattern_calculator import _compute_wide_and_loose
final = {"depth_pct": 17.6, "duration_days": 7}
assert (
_compute_wide_and_loose([{"depth_pct": 20.0, "duration_days": 25}, final], 15.0) is True
)
def test_compute_wide_and_loose_false_deep_but_long(self):
from calculators.vcp_pattern_calculator import _compute_wide_and_loose
final = {"depth_pct": 17.6, "duration_days": 15} # duration >= 10
assert (
_compute_wide_and_loose([{"depth_pct": 20.0, "duration_days": 25}, final], 15.0)
is False
)
def test_compute_wide_and_loose_false_short_but_tight(self):
from calculators.vcp_pattern_calculator import _compute_wide_and_loose
final = {"depth_pct": 5.6, "duration_days": 6} # depth <= threshold
assert (
_compute_wide_and_loose([{"depth_pct": 20.0, "duration_days": 25}, final], 15.0)
is False
)
def test_compute_wide_and_loose_false_empty(self):
from calculators.vcp_pattern_calculator import _compute_wide_and_loose
assert _compute_wide_and_loose([], 15.0) is False
def test_compute_wide_and_loose_boundary_exactly_threshold(self):
from calculators.vcp_pattern_calculator import _compute_wide_and_loose
# depth == threshold is NOT > threshold → False
final = {"depth_pct": 15.0, "duration_days": 5}
assert _compute_wide_and_loose([final], 15.0) is False
def test_compute_wide_and_loose_boundary_exactly_10_days(self):
from calculators.vcp_pattern_calculator import _compute_wide_and_loose
# duration == 10 is NOT < 10 → False
final = {"depth_pct": 20.0, "duration_days": 10}
assert _compute_wide_and_loose([final], 15.0) is False
class TestRightSideTightness:
"""Tests for right_side_range_ratio field in calculate_vcp_pattern()."""
def test_right_side_range_ratio_in_result(self):
prices = _make_prices(150, start=100.0, daily_change=0.001)
result = calculate_vcp_pattern(prices)
assert "right_side_range_ratio" in result
def test_right_side_range_lower_on_tight_base(self):
"""A price series with tight recent bars should have lower ratio."""
# Volatile base
volatile = _make_prices(80, start=100.0, daily_change=0.0)
for p in volatile:
p["high"] = p["close"] * 1.04
p["low"] = p["close"] * 0.96
# Tight recent bars (last 15 are quiet)
tight = _make_prices(70, start=volatile[-1]["close"], daily_change=0.0)
for p in tight:
p["high"] = p["close"] * 1.001
p["low"] = p["close"] * 0.999
prices_tight = tight + volatile # most-recent-first
# Loose recent bars (last 15 are wide)
loose = _make_prices(70, start=volatile[-1]["close"], daily_change=0.0)
for p in loose:
p["high"] = p["close"] * 1.04
p["low"] = p["close"] * 0.96
prices_loose = loose + volatile # most-recent-first
result_tight = calculate_vcp_pattern(prices_tight)
result_loose = calculate_vcp_pattern(prices_loose)
if (
result_tight.get("right_side_range_ratio") is not None
and result_loose.get("right_side_range_ratio") is not None
):
assert result_tight["right_side_range_ratio"] < result_loose["right_side_range_ratio"]
def test_right_side_range_ratio_none_for_short_data(self):
prices = _make_prices(35, start=100.0, daily_change=0.0)
result = calculate_vcp_pattern(prices)
# With only 35 bars, atr_50 may be 0 → ratio is None
# (result depends on atr_50 availability)
assert "right_side_range_ratio" in result
class TestPhase3Integration:
"""Integration: analyze_stock() carries Phase 3 fields through."""
def test_analyze_stock_contains_atr_compression_ratio(self):
prices = _make_prices(250, start=100.0, daily_change=0.001, volume=1_000_000)
sp500 = _make_prices(250, start=400.0, daily_change=0.0005)
quote = {
"price": prices[0]["close"],
"marketCap": 10_000_000_000,
"yearHigh": max(p["high"] for p in prices),
"yearLow": min(p["low"] for p in prices),
}
result = analyze_stock("TEST", prices, quote, sp500)
assert result is not None
# atr_compression_ratio should be passed through from vcp_result
vcp = result.get("vcp_pattern", {})
assert "atr_compression_ratio" in vcp
assert "wide_and_loose" in vcp
assert "right_side_range_ratio" in vcp
def test_wide_and_loose_propagated_to_scorer(self):
"""wide_and_loose=True from vcp_result should cap Textbook → Strong in scorer."""
prices = _make_prices(250, start=100.0, daily_change=0.001, volume=1_000_000)
sp500 = _make_prices(250, start=400.0, daily_change=0.0005)
quote = {
"price": prices[0]["close"],
"marketCap": 10_000_000_000,
"yearHigh": max(p["high"] for p in prices),
"yearLow": min(p["low"] for p in prices),
}
result = analyze_stock("TEST", prices, quote, sp500)
assert result is not None
# If wide_and_loose was True, rating must not be Textbook VCP
if result["vcp_pattern"].get("wide_and_loose"):
assert result["rating"] != "Textbook VCP"
# ---------------------------------------------------------------------------
# Phase 4: Volume Pattern — breakout bar exclusion, breakout_volume_score,
# strengthened declining contraction bonus
# ---------------------------------------------------------------------------
class TestVolumeBreakoutBarExclusion:
"""Bar[0] must be excluded from dry-up calculation."""
def test_high_bar0_does_not_inflate_dry_up(self):
"""If bar[0] has high breakout volume, dry-up ratio should stay low."""
prices = _make_prices(60, volume=1_000_000)
# All bars 1-10 have dry volume (20% of avg)
for i in range(1, 11):
prices[i]["volume"] = 200_000
# Bar[0] has explosive breakout volume (5x avg)
prices[0]["volume"] = 5_000_000
result = calculate_volume_pattern(prices)
# With bar[0] excluded, dry-up ratio should reflect bars 1-10 only
# avg(bars 1-10) ≈ 200k, 50d avg ≈ 900k+ (diluted by high bar 0)
# Ratio should be < 0.5 (bars 1-10 are truly dry)
assert result["dry_up_ratio"] < 0.5
def test_legacy_window_uses_bars_1_to_11(self):
"""Legacy window (no contractions) uses volumes[1:11] — 10 bars."""
prices = _make_prices(60, volume=1_000_000)
# Set bars 1-10 to exactly half of avg volume
# Set bar 0 to 0 — would inflate ratio if included
for i in range(1, 11):
prices[i]["volume"] = 500_000
prices[0]["volume"] = 0 # would cause very low ratio if included
result = calculate_volume_pattern(prices)
# If bar[0] were included, ratio would be much lower than 0.5
# Since bar[0] is excluded, ratio ≈ 0.5 (bars 1-10 at half avg)
# (actual 50d avg is also diluted but rough check)
assert result["dry_up_ratio"] is not None
assert result["dry_up_ratio"] > 0 # bar[0]=0 not included
class TestBreakoutVolumeScore:
"""Tests for breakout_volume_score independent field."""
def test_breakout_volume_score_field_exists(self):
prices = _make_prices(60, volume=1_000_000)
result = calculate_volume_pattern(prices, pivot_price=None)
assert "breakout_volume_score" in result
def test_breakout_volume_score_zero_when_no_pivot(self):
"""Without a pivot price, no breakout can be assessed."""
prices = _make_prices(60, volume=1_000_000)
result = calculate_volume_pattern(prices, pivot_price=None)
assert result["breakout_volume_score"] == 0
def test_breakout_volume_score_60_when_above_pivot_with_15x(self):
"""Bar[0] above pivot at 1.5x avg → score = 60."""
prices = _make_prices(60, volume=1_000_000)
avg = 1_000_000
# Bar[0] at 1.5x avg, price above pivot
prices[0]["volume"] = int(avg * 1.6)
prices[0]["close"] = 105.0 # above pivot 100
pivot = 100.0
result = calculate_volume_pattern(prices, pivot_price=pivot, breakout_volume_ratio=1.5)
assert result["breakout_volume_score"] == 60
def test_breakout_volume_score_100_at_3x(self):
"""Bar[0] at 3x+ avg above pivot → score = 100."""
prices = _make_prices(60, volume=1_000_000)
prices[0]["volume"] = 3_200_000 # 3.2x avg
prices[0]["close"] = 105.0
pivot = 100.0
result = calculate_volume_pattern(prices, pivot_price=pivot)
assert result["breakout_volume_score"] == 100
def test_breakout_volume_score_zero_when_below_pivot(self):
"""Even high volume on bar[0] doesn't score if price is below pivot."""
prices = _make_prices(60, volume=1_000_000)
prices[0]["volume"] = 3_000_000
prices[0]["close"] = 95.0 # BELOW pivot
pivot = 100.0
result = calculate_volume_pattern(prices, pivot_price=pivot)
assert result["breakout_volume_score"] == 0
class TestDecliningContractionBonus:
"""Declining contraction bonus strengthened from +5 to +10."""
def test_declining_bonus_is_10_not_5(self):
"""Verify declining contraction bonus contributes exactly +10 to score.
Design: 150 bars, contraction periods placed OUTSIDE 50d avg window
(indices 0-49 most-recent-first) and Zone B (indices 1-10).
This ensures both cases have identical base_score; the only difference
is the declining contraction volume bonus.
T1: chron 10-40 → most-recent-first indices 109-139 (outside 50d window)
T2: chron 45-60 → most-recent-first indices 89-104 (outside 50d window)
"""
prices_declining = _make_prices(150, volume=1_000_000)
# T1 period (most-recent idx 109-139): high volume
for i in range(109, 140):
prices_declining[i]["volume"] = 800_000
# T2 period (most-recent idx 89-104): low volume → T1 > T2 → declining
for i in range(89, 105):
prices_declining[i]["volume"] = 300_000
prices_flat = _make_prices(150, volume=1_000_000) # all 1M
contractions = [
{"high_idx": 10, "low_idx": 40, "label": "T1"},
{"high_idx": 45, "low_idx": 60, "label": "T2"},
]
r_dec = calculate_volume_pattern(
prices_declining, pivot_price=101.0, contractions=contractions
)
r_flat = calculate_volume_pattern(prices_flat, pivot_price=101.0, contractions=contractions)
assert r_dec.get("contraction_volume_trend", {}).get("declining") is True
assert r_flat.get("contraction_volume_trend", {}).get("declining") is False
assert r_dec["score"] - r_flat["score"] == 10
# ---------------------------------------------------------------------------
# Phase 5: Report 2-axis format, default changes, strict mode
# ---------------------------------------------------------------------------
def _make_stock(
symbol="AAPL",
composite_score=82.0,
rating="Strong VCP",
execution_state="Pre-breakout",
pattern_type="Textbook VCP",
entry_ready=True,
state_cap_applied=False,
distance_from_pivot_pct=-2.0,
price=150.0,
valid_vcp=True,
):
"""Build a minimal stock result dict for report tests."""
return {
"symbol": symbol,
"company_name": f"{symbol} Corp",
"sector": "Technology",
"price": price,
"market_cap": 10e9,
"composite_score": composite_score,
"rating": rating,
"rating_description": "",
"guidance": "Buy at pivot",
"valid_vcp": valid_vcp,
"entry_ready": entry_ready,
"execution_state": execution_state,
"pattern_type": pattern_type,
"state_cap_applied": state_cap_applied,
"cap_reason": None,
"distance_from_pivot_pct": distance_from_pivot_pct,
"weakest_component": "volume",
"weakest_score": 50,
"strongest_component": "trend",
"strongest_score": 95,
"trend_template": {"score": 95, "criteria_passed": 7, "extended_penalty": 0},
"vcp_pattern": {
"score": 80,
"num_contractions": 3,
"contractions": [
{"label": "T1", "depth_pct": 12.0},
{"label": "T2", "depth_pct": 8.0},
{"label": "T3", "depth_pct": 5.0},
],
"pivot_price": 155.0,
},
"volume_pattern": {"score": 70, "dry_up_ratio": 0.4},
"pivot_proximity": {
"score": 75,
"distance_from_pivot_pct": distance_from_pivot_pct,
"stop_loss_price": 148.0,
"risk_pct": 5.0,
"trade_status": "PRE-BREAKOUT",
},
"relative_strength": {"score": 80, "rs_rank_estimate": 85, "weighted_rs": 8.0},
}
class TestPhase5ReportFormat:
"""Tests for 2-axis header format in report_generator."""
def test_quick_scan_table_present(self):
"""Quick Scan summary table should appear before Section A."""
stock = _make_stock()
metadata = {"generated_at": "2025-01-01", "funnel": {}}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "## Quick Scan" in content
assert "| # | Symbol | Quality | State | Type | Price | Pivot Dist |" in content
def test_quick_scan_lists_symbol_and_state(self):
"""Quick Scan table should include symbol, execution state, and pattern type."""
stock = _make_stock(symbol="NVDA", execution_state="Breakout", pattern_type="VCP-adjacent")
metadata = {"generated_at": "2025-01-01", "funnel": {}}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "NVDA" in content
assert "Breakout" in content
assert "VCP-adjacent" in content
def test_state_cap_marker_shown_in_quick_scan(self):
"""★ marker should appear when state_cap_applied=True."""
stock = _make_stock(state_cap_applied=True)
metadata = {"generated_at": "2025-01-01", "funnel": {}}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "★" in content
assert "State Cap applied" in content
def test_two_axis_header_in_stock_entry(self):
"""Stock entry should show Quality | State | Type on one line."""
stock = _make_stock(
composite_score=88.0,
rating="Strong VCP",
execution_state="Pre-breakout",
pattern_type="Textbook VCP",
)
metadata = {"generated_at": "2025-01-01", "funnel": {}}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "**Quality:** 88/100 (Strong VCP)" in content
assert "**State:** Pre-breakout" in content
assert "**Type:** Textbook VCP" in content
def test_old_vcp_score_format_absent(self):
"""Old '**VCP Score:**' format should not appear in report."""
stock = _make_stock()
metadata = {"generated_at": "2025-01-01", "funnel": {}}
with tempfile.TemporaryDirectory() as tmpdir:
md_file = os.path.join(tmpdir, "test.md")
generate_markdown_report([stock], metadata, md_file)
with open(md_file) as f:
content = f.read()
assert "**VCP Score:**" not in content
class TestPhase5DefaultChanges:
"""Tests for changed CLI default values."""
def test_t1_depth_min_default_is_10(self):
"""--t1-depth-min should default to 10.0."""
import sys
orig = sys.argv
sys.argv = ["screen_vcp.py"]
try:
args = parse_arguments()
assert args.t1_depth_min == 10.0
finally:
sys.argv = orig
def test_contraction_ratio_default_is_070(self):
"""--contraction-ratio should default to 0.70."""
import sys
orig = sys.argv
sys.argv = ["screen_vcp.py"]
try:
args = parse_arguments()
assert args.contraction_ratio == 0.70
finally:
sys.argv = orig
def test_strict_flag_defaults_false(self):
"""--strict should default to False."""
import sys
orig = sys.argv
sys.argv = ["screen_vcp.py"]
try:
args = parse_arguments()
assert args.strict is False
finally:
sys.argv = orig
def test_strict_flag_set_when_passed(self):
"""--strict flag should set strict=True."""
import sys
orig = sys.argv
sys.argv = ["screen_vcp.py", "--strict"]
try:
args = parse_arguments()
assert args.strict is True
finally:
sys.argv = orig
class TestPhase5StrictMode:
"""Tests for strict mode filtering in compute_entry_ready and analyze_stock."""
def test_strict_requires_valid_vcp(self):
"""Strict mode: invalid VCP should be excluded (valid_vcp=False)."""
# Stock with valid_vcp=False and correct execution_state
stock = _make_stock(valid_vcp=False, execution_state="Pre-breakout")
# strict filtering is: valid_vcp AND execution_state in (Pre-breakout, Breakout)
strict_ok = stock.get("valid_vcp", False) and stock.get("execution_state") in (
"Pre-breakout",
"Breakout",
)
assert strict_ok is False
def test_strict_requires_pre_breakout_or_breakout(self):
"""Strict mode: Extended/Overextended states should be excluded."""
for state in ("Extended", "Overextended", "Early-post-breakout", "Damaged", "Invalid"):
stock = _make_stock(valid_vcp=True, execution_state=state)
strict_ok = stock.get("valid_vcp", False) and stock.get("execution_state") in (
"Pre-breakout",
"Breakout",
)
assert strict_ok is False, f"Expected strict to exclude state={state}"
def test_strict_passes_pre_breakout_with_valid_vcp(self):
"""Strict mode: valid_vcp=True + Pre-breakout should pass."""
stock = _make_stock(valid_vcp=True, execution_state="Pre-breakout")
strict_ok = stock.get("valid_vcp", False) and stock.get("execution_state") in (
"Pre-breakout",
"Breakout",
)
assert strict_ok is True
def test_strict_passes_breakout_with_valid_vcp(self):
"""Strict mode: valid_vcp=True + Breakout should pass."""
stock = _make_stock(valid_vcp=True, execution_state="Breakout")
strict_ok = stock.get("valid_vcp", False) and stock.get("execution_state") in (
"Pre-breakout",
"Breakout",
)
assert strict_ok is True
# ---------------------------------------------------------------------------
# Regression: wide_and_loose preserved through rerank path
# ---------------------------------------------------------------------------
class TestWideAndLooseRerank:
"""Verify wide_and_loose is persisted in result dict for rerank path."""
def test_wide_and_loose_in_analyze_stock_result(self):
"""analyze_stock() must include wide_and_loose at top level."""
prices = _make_prices(250, start=100.0, daily_change=0.001, volume=1_000_000)
sp500 = _make_prices(250, start=400.0, daily_change=0.0005)
quote = {
"price": prices[0]["close"],
"marketCap": 10_000_000_000,
"yearHigh": prices[0]["high"] * 1.1,
"yearLow": prices[-1]["low"],
}
result = analyze_stock(
symbol="TEST",
historical=prices,
quote=quote,
sp500_history=sp500,
)
assert result is not None
assert "wide_and_loose" in result
assert isinstance(result["wide_and_loose"], bool)
def test_wide_and_loose_cap_survives_rerank(self):
"""When wide_and_loose=True, re-calling calculate_composite_score must
still apply the Developing VCP cap (not lose it)."""
result = calculate_composite_score(
trend_score=95.0,
contraction_score=95.0,
volume_score=90.0,
pivot_score=90.0,
rs_score=90.0,
valid_vcp=True,
execution_state="Pre-breakout",
wide_and_loose=True,
)
assert result["rating"] == "Developing VCP"
# Simulate rerank: same call again with updated RS
result2 = calculate_composite_score(
trend_score=95.0,
contraction_score=95.0,
volume_score=90.0,
pivot_score=90.0,
rs_score=85.0, # changed RS
valid_vcp=True,
execution_state="Pre-breakout",
wide_and_loose=True, # must still be passed
)
assert result2["rating"] == "Developing VCP"
assert result2["state_cap_applied"] is True
# ---------------------------------------------------------------------------
# Regression: Early-post-breakout state cap
# ---------------------------------------------------------------------------
class TestEarlyPostBreakoutCap:
"""Verify Early-post-breakout caps at Strong VCP."""
def test_early_post_breakout_caps_textbook(self):
"""Textbook score + Early-post-breakout → Strong VCP (not Textbook)."""
result = calculate_composite_score(
trend_score=95.0,
contraction_score=95.0,
volume_score=90.0,
pivot_score=90.0,
rs_score=90.0,
valid_vcp=True,
execution_state="Early-post-breakout",
)
assert result["rating"] == "Strong VCP"
assert result["state_cap_applied"] is True
def test_early_post_breakout_does_not_cap_strong(self):
"""Strong VCP + Early-post-breakout → Strong VCP (no downgrade)."""
result = calculate_composite_score(
trend_score=85.0,
contraction_score=85.0,
volume_score=80.0,
pivot_score=80.0,
rs_score=80.0,
valid_vcp=True,
execution_state="Early-post-breakout",
)
assert result["rating"] == "Strong VCP"
assert result["state_cap_applied"] is False
def test_pivot_above_no_volume_is_early_post_breakout(self):
"""0-3% above pivot without volume → Early-post-breakout (not Pre-breakout)."""
from calculators.execution_state import compute_execution_state
result = compute_execution_state(
distance_from_pivot_pct=1.5,
price=102.0,
sma50=98.0,
sma200=90.0,
sma200_distance_pct=13.3,
last_contraction_low=None,
breakout_volume=False,
)
assert result["state"] == "Early-post-breakout"