返回 Skills
tradermonty/claude-trading-skills· MIT 内容可用

vcp-screener

Screen S&P 500 stocks for Mark Minervini's Volatility Contraction Pattern (VCP) and detect historical VCPs in a single ticker's price path. Identifies Stage 2 uptrend stocks forming tight bases with contracting volatility near breakout pivot points; in historical single-ticker mode walks a multi-year history and emits every VCP that formed with forward-outcome stats (breakout / stop-hit / timeout). Use when user requests VCP screening, Minervini-style setups, tight base patterns, volatility contraction breakout candidates, Stage 2 momentum stock scanning, or historical VCP pattern study on a specific ticker (e.g. FIX, TSLA).

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: vcp-screener description: Screen S&P 500 stocks for Mark Minervini's Volatility Contraction Pattern (VCP) and detect historical VCPs in a single ticker's price path. Identifies Stage 2 uptrend stocks forming tight bases with contracting volatility near breakout pivot points; in historical single-ticker mode walks a multi-year history and emits every VCP that formed with forward-outcome stats (breakout / stop-hit / timeout). Use when user requests VCP screening, Minervini-style setups, tight base patterns, volatility contraction breakout candidates, Stage 2 momentum stock scanning, or historical VCP pattern study on a specific ticker (e.g. FIX, TSLA).

VCP Screener - Minervini Volatility Contraction Pattern

Screen S&P 500 stocks for Mark Minervini's Volatility Contraction Pattern (VCP), identifying Stage 2 uptrend stocks with contracting volatility near breakout pivot points.

When to Use

  • User asks for VCP screening or Minervini-style setups
  • User wants to find tight base / volatility contraction patterns
  • User requests Stage 2 momentum stock scanning
  • User asks for breakout candidates with defined risk
  • User asks "find every historical VCP in <TICKER>" or wants to study one ticker's past VCP setups with forward outcomes (--history --ticker SYM)

Prerequisites

  • FMP API key (set FMP_API_KEY environment variable or pass --api-key)
  • Free tier (250 calls/day) is sufficient for default screening (top 100 candidates)
  • Paid tier recommended for full S&P 500 screening (--full-sp500)

Workflow

Step 1: Prepare and Execute Screening

Run the VCP screener script:

# Default: S&P 500, top 100 candidates
python3 skills/vcp-screener/scripts/screen_vcp.py --output-dir skills/vcp-screener/scripts

# Custom universe
python3 skills/vcp-screener/scripts/screen_vcp.py --universe AAPL NVDA MSFT AMZN META --output-dir skills/vcp-screener/scripts

# Full S&P 500 (paid API tier)
python3 skills/vcp-screener/scripts/screen_vcp.py --full-sp500 --output-dir skills/vcp-screener/scripts

Strict Mode (Minervini pure setup)

Only return stocks with valid_vcp=True AND execution_state in (Pre-breakout, Breakout):

python3 skills/vcp-screener/scripts/screen_vcp.py --strict --output-dir reports/

Historical single-ticker mode

Walk one ticker's multi-year history, detect every VCP that ever formed, and attach forward-outcome stats (breakout / stop-hit / timeout, days-to-outcome, max gain, max loss) per detection. Useful for pattern study and backtesting context — not a real-time screener.

# Default: scan ~5 years (1260 trading days), 5-day stride, 60-day outcome window
python3 skills/vcp-screener/scripts/screen_vcp.py \
  --history --ticker FIX --output-dir reports/

# Custom scan length: 750 trading days (~3 years), 90-day outcome window
python3 skills/vcp-screener/scripts/screen_vcp.py \
  --history 750 --ticker TSLA \
  --stride-days 5 --outcome-days 90 \
  --output-dir reports/

# Long scan: 10 years (2520 trading days)
python3 skills/vcp-screener/scripts/screen_vcp.py \
  --history 2520 --ticker NVDA --output-dir reports/

Outputs (timestamped):

  • vcp_history_<SYM>_<YYYY-MM-DD_HHMMSS>.json — timeline of detections with full analyzer payload + forward_outcome per detection + summary stats.
  • vcp_history_<SYM>_<YYYY-MM-DD_HHMMSS>.md — human-readable timeline.

Mode-specific flags:

ParameterDefaultRangeEffect
--history [DAYS](off) / 1260 if bare100-5040Enable historical mode; optionally specify trading-day scan window (requires --ticker)
--ticker SYMTicker to scan
--stride-days51-60Trading-day step between as-of cursor positions
--outcome-days605-252Forward window evaluated per detection

Notes:

  • Two FMP API calls per scan (ticker + SPY history), not 100+ like the cross-sectional pipeline.
  • 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.
  • Detections are deduplicated by (T1_high_date, last_low_date, pivot) so the same VCP isn't reported repeatedly as the cursor ages.

Advanced Tuning (for backtesting)

Adjust VCP detection parameters for research and backtesting:

python3 skills/vcp-screener/scripts/screen_vcp.py \
  --min-contractions 3 \
  --t1-depth-min 12.0 \
  --breakout-volume-ratio 2.0 \
  --trend-min-score 90 \
  --atr-multiplier 1.5 \
  --output-dir reports/
ParameterDefaultRangeEffect
--min-contractions22-4Higher = fewer but higher-quality patterns
--t1-depth-min10.0%1-50Higher = excludes shallow first corrections
--breakout-volume-ratio1.5x0.5-10Higher = stricter volume confirmation
--trend-min-score850-100Higher = stricter Stage 2 filter
--atr-multiplier1.50.5-5Lower = more sensitive swing detection
--contraction-ratio0.700.1-1Lower = requires tighter contractions
--min-contraction-days51-30Higher = longer minimum contraction
--lookback-days12030-365Longer = finds older patterns
--max-sma200-extension50.0%SMA200 distance threshold for Overextended state and penalty
--wide-and-loose-threshold15.0%Final contraction depth above which wide-and-loose flag triggers
--strictoffMinervini strict mode: only Pre-breakout or Breakout with valid VCP

Step 2: Review Results

  1. Read the generated JSON and Markdown reports
  2. Load references/vcp_methodology.md for pattern interpretation context
  3. Load references/scoring_system.md for score threshold guidance

Step 3: Present Analysis

For each top candidate, present:

  • Quality (composite_score / rating) — how well-formed is the VCP pattern?
  • Execution State (execution_state) — is it buyable now? (Pre-breakout / Breakout = actionable)
  • Pattern Type (pattern_type) — Textbook VCP / VCP-adjacent / Post-breakout / Extended Leader / Damaged
  • marker if a State Cap was applied (raw score was downgraded)
  • Contraction details (T1/T2/T3 depths and ratios)
  • Trade setup: pivot price, stop-loss, risk percentage
  • Volume dry-up ratio and breakout_volume_score
  • Relative strength rank

Step 4: Provide Actionable Guidance

By Execution State (primary filter):

  • Pre-breakout / Breakout: Pattern is in the active entry window — apply rating-based sizing
  • Early-post-breakout: Breakout underway but above ideal entry — reduced size or wait for pullback
  • Extended / Overextended: Trade missed — add to watchlist for next base
  • Damaged / Invalid: Setup invalidated — do not enter

By Rating (secondary, after state confirms actionability):

  • Textbook VCP (90+): Buy at pivot with aggressive sizing (1.5-2x)
  • Strong VCP (80-89): Buy at pivot with standard sizing (1x)
  • Good VCP (70-79): Buy on volume confirmation above pivot (0.75x)
  • Developing (60-69): Add to watchlist, wait for tighter contraction
  • Weak/No VCP (<60): Monitor only or skip

3-Phase Pipeline

  1. Pre-Filter - Quote-based screening (price, volume, 52w position) ~101 API calls
  2. Trend Template - 7-point Stage 2 filter with 260-day histories ~100 API calls
  3. VCP Detection - Pattern analysis, scoring, report generation (no additional API calls)

Output

  • vcp_screener_YYYY-MM-DD_HHMMSS.json - Structured results
  • vcp_screener_YYYY-MM-DD_HHMMSS.md - Human-readable report

Resources

  • references/vcp_methodology.md - VCP theory and Trend Template explanation
  • references/scoring_system.md - Scoring thresholds and component weights
  • references/fmp_api_endpoints.md - API endpoints and rate limits

附带文件

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"
    vcp-screener | Prompt Minder