
Pead Screener
- 905 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
pead-screener is a Claude Code skill that screens post-earnings gap-up US stocks for PEAD patterns using weekly candle analysis for developers hunting drift and breakout setups.
About
pead-screener is a Post-Earnings Announcement Drift screening skill for US stocks that gapped up after earnings. It analyzes weekly candle formations to flag red-candle pullbacks and breakout signals tied to earnings momentum. Two input modes are supported: Mode A pulls from the FMP earnings calendar and Mode B ingests earnings-trade-analyzer JSON output. Developers invoke pead-screener when researching post-earnings drift, gap follow-through, red candle breakout patterns, or weekly earnings momentum setups instead of manual chart review across many tickers.
- Detects red candle pullbacks and breakout signals in weekly charts
- Supports two input modes: FMP earnings calendar (Mode A) or earnings-trade-analyzer JSON (Mode B)
- Triggers on queries about PEAD screening, post-earnings drift, earnings gap follow-through, red candle breakout patterns
- Requires only free-tier FMP API access (250 calls/day)
- Outputs screened stocks with PEAD pattern confidence
Pead Screener by the numbers
- 905 all-time installs (skills.sh)
- +74 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #169 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill pead-screenerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 905 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you screen stocks for post-earnings drift patterns?
Automatically screen post-earnings gap-up stocks for PEAD patterns using weekly candle analysis.
Who is it for?
Equity traders and quant developers screening US post-earnings gap-ups for PEAD drift using FMP or analyzer JSON feeds.
Skip if: Long-horizon fundamental investors, non-US markets, or strategies ignoring earnings-gap weekly candle structure.
When should I use this skill?
User asks about PEAD screening, post-earnings drift, earnings gap follow-through, or red candle breakout patterns.
What you get
PEAD candidate watchlists with weekly red-candle pullback and breakout signal annotations from earnings gap-ups.
- PEAD candidate screen results
- Weekly candle signal annotations
Files
PEAD Screener - Post-Earnings Announcement Drift
Screen post-earnings gap-up stocks for PEAD (Post-Earnings Announcement Drift) patterns using weekly candle analysis to detect red candle pullbacks and breakout signals.
When to Use
- User asks for PEAD screening or post-earnings drift analysis
- User wants to find earnings gap-up stocks with follow-through potential
- User requests red candle breakout patterns after earnings
- User asks for weekly earnings momentum setups
- User provides earnings-trade-analyzer JSON output for further screening
Prerequisites
- FMP API key (set
FMP_API_KEYenvironment variable or pass--api-key)
export FMP_API_KEY=your_api_key_here- Free tier (250 calls/day) is sufficient for default screening
- For Mode B: earnings-trade-analyzer JSON output file with schema_version "1.0"
Workflow
Step 1: Prepare and Execute Screening
Run the PEAD screener script in one of two modes:
Mode A (FMP earnings calendar):
# Default: last 14 days of earnings, 5-week monitoring window
python3 skills/pead-screener/scripts/screen_pead.py --output-dir reports/
# Custom parameters
python3 skills/pead-screener/scripts/screen_pead.py \
--lookback-days 21 \
--watch-weeks 6 \
--min-gap 5.0 \
--min-market-cap 1000000000 \
--output-dir reports/Mode B (earnings-trade-analyzer JSON input):
# From earnings-trade-analyzer output
python3 skills/pead-screener/scripts/screen_pead.py \
--candidates-json reports/earnings_trade_analyzer_YYYY-MM-DD_HHMMSS.json \
--min-grade B \
--output-dir reports/Scheduled US-equity routine pitfall: Prefer Mode B for pre-market / US-equity cron briefs after running earnings-trade-analyzer. Mode A can pull the global FMP earnings calendar, spend the API budget on non-US symbols, and return weak/non-actionable foreign listings before reaching the intended US watchlist. If Mode A is used anyway and the script reports budget trimming or non-US symbols, mark PEAD output as degraded and treat it as manual-review only rather than a clean candidate source.
Step 2: Review Results
1. Read the generated JSON and Markdown reports 2. Load references/pead_strategy.md for PEAD theory and pattern context 3. Load references/entry_exit_rules.md for trade management rules
Step 3: Present Analysis
For each candidate, present:
- Stage classification (MONITORING, SIGNAL_READY, BREAKOUT, EXPIRED)
- Weekly candle pattern details (red candle location, breakout status)
- Composite score and rating
- Trade setup: entry, stop-loss, target, risk/reward ratio
- Liquidity metrics (ADV20, average volume)
Step 4: Provide Actionable Guidance
Based on stages and ratings:
- BREAKOUT + Strong Setup (85+): High-conviction PEAD trade, full position size
- BREAKOUT + Good Setup (70-84): Solid PEAD setup, standard position size
- SIGNAL_READY: Red candle formed, set alert for breakout above red candle high
- MONITORING: Post-earnings, no red candle yet, add to watchlist
- EXPIRED: Beyond monitoring window, remove from watchlist
Output
pead_screener_YYYY-MM-DD_HHMMSS.json- Structured results with stage classificationpead_screener_YYYY-MM-DD_HHMMSS.md- Human-readable report grouped by stage
Resources
references/pead_strategy.md- PEAD theory and weekly candle approachreferences/entry_exit_rules.md- Entry, exit, and position sizing rules
PEAD Entry and Exit Rules
Entry Rules
Primary Entry: Red Candle Breakout
- Trigger: Weekly candle closes above the red candle's high on a green candle (close >= open)
- Entry Price: At or slightly above the red candle high
- Confirmation: Volume on the breakout week should be above the 4-week average
- Timing: Enter after the weekly close confirms the breakout (end of Friday session)
Gap Minimum Requirement
- Minimum 3% gap-up on earnings day to qualify as a PEAD candidate
- Larger gaps (5%+) generally indicate stronger earnings surprises and more persistent drift
- Gaps below 3% may represent noise rather than genuine earnings surprise
Pre-Entry Checklist
1. Earnings gap-up was at least 3% 2. A clear red weekly candle has formed (not a doji or inside bar) 3. Current weekly candle is green with close above red candle high 4. ADV20 (20-day average dollar volume) is at least $25M for adequate liquidity 5. Stock price is above $10 to avoid penny stock volatility 6. Within the 5-week monitoring window from earnings date
Exit Rules
Stop-Loss
- Level: Below the red candle's low
- Type: Hard stop (not mental stop)
- Rationale: If price drops below the red candle low, the pullback pattern has failed and institutional support has broken
Profit Target
- Primary Target: 2R (2x the risk from entry to stop)
- Calculation: Target = Entry + (Entry - Stop) x 2.0
- Example: Entry at $100, Stop at $95 (5% risk) -> Target at $110 (10% gain, 2:1 R:R)
Trailing Stop (Optional)
- After reaching 1R profit, move stop to breakeven
- After reaching 1.5R profit, trail stop at 1R below current price
- This locks in profits while allowing the PEAD drift to continue
Time-Based Exit
- If the position has not reached the profit target within 4 weeks of entry, consider closing for a scratch or small profit/loss
- PEAD drift weakens significantly after 6-8 weeks post-earnings
Position Sizing
Risk-Based Sizing
- Risk no more than 1-2% of portfolio value per trade
- Position size = (Portfolio x Risk%) / (Entry - Stop)
- Example: $100K portfolio, 1% risk = $1,000 risk budget
- Entry $100, Stop $95 = $5 risk per share
- Position = $1,000 / $5 = 200 shares ($20,000 position)
Liquidity-Based Constraints
- Never take a position larger than 1% of ADV20 (20-day average dollar volume)
- This ensures the position can be exited within a single day without significant market impact
- Example: ADV20 = $50M -> Max position = $500K
Portfolio-Level Constraints
- Maximum 3-5 PEAD positions simultaneously
- Diversify across sectors to avoid correlated earnings risk
- Reduce position size if multiple PEAD trades are open
Monitoring Window
Duration
- Default: 5 weeks from earnings date
- The PEAD effect is strongest in weeks 1-3 and diminishes by weeks 4-5
- After 5 weeks without a breakout signal, remove from monitoring
Weekly Review Process
1. Check if a red candle has formed (MONITORING -> SIGNAL_READY) 2. Check if breakout has occurred (SIGNAL_READY -> BREAKOUT) 3. Review volume on the breakout candle for confirmation 4. Calculate risk/reward based on current red candle levels 5. Verify liquidity is still adequate for entry
Special Situations
Multiple Red Candles
- If multiple red candles form, use the most recent one for entry/stop levels
- Multiple red candles may indicate weakening momentum; reduce position size
Gap-and-Go (No Red Candle)
- Some stocks gap up and never pull back meaningfully
- These are not PEAD screener candidates (require red candle for defined risk)
- Consider alternative entry strategies if conviction is high
Earnings in Consecutive Quarters
- A stock that gaps up on earnings for 2+ consecutive quarters shows persistent fundamental strength
- These are higher-conviction PEAD candidates when they form the red candle pattern
PEAD Strategy - Post-Earnings Announcement Drift
What is PEAD
Post-Earnings Announcement Drift (PEAD) is one of the most robust anomalies in financial markets. Stocks that report positive earnings surprises (and gap up on the announcement) tend to continue drifting higher over the following weeks and months. This drift represents a systematic underreaction by the market to new earnings information.
Academic Foundation
Ball & Brown (1968)
The seminal paper by Ray Ball and Philip Brown first documented that stock prices continue to drift in the direction of earnings surprises for up to 60 days after the announcement. This was one of the earliest challenges to the Efficient Market Hypothesis.
Bernard & Thomas (1989)
Victor Bernard and Jacob Thomas provided the most comprehensive analysis of PEAD. Their findings showed:
- Stocks in the highest earnings surprise decile outperformed the lowest decile by approximately 4% in the 60 days following the announcement
- The drift was strongest in the first 2-3 weeks post-announcement
- Smaller, less liquid stocks exhibited stronger drift
- The anomaly persisted across different time periods and market conditions
Foster, Olsen & Shevlin (1984)
Confirmed that the drift is proportional to the magnitude of the earnings surprise, with larger surprises producing more persistent drift.
Why It Works: Market Underreaction
PEAD exists because of several behavioral and structural factors:
1. Anchoring Bias: Analysts and investors anchor to prior earnings estimates and adjust insufficiently to new information 2. Gradual Information Diffusion: Not all market participants process earnings information simultaneously; institutional investors, retail traders, and algorithmic systems react on different timelines 3. Confirmation Bias: Investors who were bearish before earnings may dismiss a positive surprise as a one-time event 4. Liquidity Constraints: Large institutional investors cannot immediately establish full positions without moving the market 5. Post-Earnings Volatility Risk: Many traders avoid the immediate post-earnings period due to elevated implied volatility, creating a delayed response
Weekly Candle Approach
This screener uses weekly candle analysis rather than daily candles for several reasons:
Why Weekly Candles
1. Noise Reduction: Weekly candles filter out intraday and daily noise, revealing the true post-earnings trend 2. Institutional Footprints: Large institutions typically build positions over weeks, not days; weekly candles capture their accumulation patterns 3. Clear Pattern Recognition: Red and green weekly candles provide unambiguous signals about buyer/seller dominance 4. Manageable Monitoring: Weekly cadence allows systematic monitoring without requiring daily attention
Red Candle Pullback Pattern
The core pattern this screener identifies:
1. Earnings Gap-Up: Stock gaps up 3%+ on earnings announcement (green weekly candle) 2. Post-Earnings Drift: Stock may continue higher for 1-2 weeks (green candles) 3. Red Candle Pullback: An orderly pullback produces a red weekly candle (close < open) 4. Breakout Signal: When the next green candle closes above the red candle's high, this signals the pullback is complete and the PEAD trend is resuming
This pattern works because:
- The red candle represents profit-taking by short-term traders
- The lower wick of the red candle reveals where institutional buyers are willing to support the stock
- The breakout above the red candle high confirms that demand exceeds supply
Stage-Based Monitoring System
The screener classifies each stock into one of four stages:
MONITORING
- Stock has gapped up on earnings within the watch window
- No red weekly candle has formed yet
- Action: Add to watchlist, check weekly for red candle formation
SIGNAL_READY
- A red weekly candle has formed after the earnings gap-up
- No breakout above the red candle high yet
- Action: Set price alert at red candle high; prepare order for breakout
BREAKOUT
- Current weekly candle is green AND price is above the red candle high
- This is the actionable trade signal
- Action: Enter position with stop below red candle low
EXPIRED
- More than 5 weeks (configurable) have passed since earnings
- The PEAD effect diminishes significantly after this window
- Action: Remove from watchlist
Historical Performance Characteristics
Based on academic research and practical observations:
- Win Rate: PEAD strategies historically show 55-65% win rates
- Average Winner vs. Loser: Winners tend to be 1.5-2.5x larger than losers
- Optimal Holding Period: 2-6 weeks post-entry for the core PEAD drift
- Sector Sensitivity: Technology and growth sectors tend to show stronger PEAD effects
- Market Cap Effect: Mid-cap stocks ($2B-$20B) often show the strongest drift due to analyst coverage gaps
- Earnings Quality: Stocks with revenue beats in addition to EPS beats show stronger drift
Key Differences from Other Momentum Strategies
Unlike pure price momentum strategies, PEAD is fundamentally driven:
- It requires a specific catalyst (earnings announcement)
- The thesis is grounded in information underreaction, not just trend following
- It has a defined entry window (post-earnings) and monitoring period
- Risk management is anchored to the red candle pattern, not arbitrary stop levels
"""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
# PEAD Screener Calculators
#!/usr/bin/env python3
"""
Breakout Calculator for PEAD Screener
Determines breakout quality when the current weekly candle closes
above the red candle's high, confirming PEAD continuation.
Scoring (25% weight in composite):
- breakout_pct >= 3% with volume: 100
- breakout_pct >= 2% with volume: 85
- breakout_pct >= 1%: 70
- breakout_pct > 0%: 55
- No breakout: 0
"""
def calculate_breakout(
weekly_candles: list[dict],
red_candle: dict,
current_price: float,
) -> dict:
"""
Determine breakout quality for a PEAD setup.
Args:
weekly_candles: Most-recent-first list of weekly candle dicts
red_candle: Red candle dict from find_red_candle()
current_price: Current stock price
Returns:
{
"is_breakout": bool,
"breakout_pct": float, # distance above red candle high
"volume_confirmation": bool, # breakout week volume > avg
"score": float # 0-100
}
"""
result = {
"is_breakout": False,
"breakout_pct": 0.0,
"volume_confirmation": False,
"score": 0.0,
}
if not red_candle or not weekly_candles:
return result
red_high = red_candle["high"]
if red_high <= 0:
return result
# Calculate breakout percentage
breakout_pct = (current_price - red_high) / red_high * 100
result["breakout_pct"] = round(breakout_pct, 2)
if breakout_pct <= 0:
result["is_breakout"] = False
result["score"] = 0.0
return result
result["is_breakout"] = True
# Check volume confirmation on the current (breakout) week
current_candle = weekly_candles[0]
volume_confirmed = _check_volume_confirmation(weekly_candles, current_candle)
result["volume_confirmation"] = volume_confirmed
# Score based on breakout percentage and volume
if breakout_pct >= 3.0 and volume_confirmed:
result["score"] = 100.0
elif breakout_pct >= 2.0 and volume_confirmed:
result["score"] = 85.0
elif breakout_pct >= 1.0:
result["score"] = 70.0
else:
result["score"] = 55.0
return result
def _check_volume_confirmation(weekly_candles: list[dict], current_candle: dict) -> bool:
"""Check if the breakout week's volume is above the 4-week average.
Args:
weekly_candles: Most-recent-first weekly candle list
current_candle: The current (breakout) week candle
Returns:
True if current volume > average of prior 4 weeks
"""
if len(weekly_candles) < 2:
return False
# Calculate average volume of prior 4 weeks (indices 1-4)
prior_volumes = []
for i in range(1, min(5, len(weekly_candles))):
vol = weekly_candles[i].get("volume", 0)
if vol > 0:
prior_volumes.append(vol)
if not prior_volumes:
return False
avg_volume = sum(prior_volumes) / len(prior_volumes)
return current_candle.get("volume", 0) > avg_volume
#!/usr/bin/env python3
"""
Liquidity Calculator for PEAD Screener
Calculates liquidity metrics for position sizing feasibility.
Thresholds:
- ADV20 (20-day avg dollar volume) >= $25M
- Average volume >= 1M shares
- Price >= $10
Scoring (25% weight in composite):
- All 3 pass + ADV20 > $100M: 100
- All 3 pass + ADV20 > $50M: 85
- All 3 pass: 70
- 2 of 3 pass: 40
- 1 or 0 pass: 15
"""
def calculate_liquidity(daily_prices: list[dict], current_price: float) -> dict:
"""
Calculate liquidity metrics for position sizing feasibility.
Args:
daily_prices: Most-recent-first list of daily price dicts with
date, open, high, low, close, volume
current_price: Current stock price
Returns:
{
"adv20_dollar": float, # 20-day average dollar volume
"avg_volume_20d": float, # 20-day average share volume
"price": float,
"passes_all": bool,
"score": float # 0-100
}
"""
result = {
"adv20_dollar": 0.0,
"avg_volume_20d": 0.0,
"price": current_price,
"passes_all": False,
"score": 15.0,
}
if not daily_prices or current_price <= 0:
return result
# Calculate 20-day average volume
recent_20 = daily_prices[:20]
volumes = [d.get("volume", 0) for d in recent_20]
if not volumes:
return result
avg_volume = sum(volumes) / len(volumes)
result["avg_volume_20d"] = round(avg_volume, 0)
# Calculate ADV20 (average dollar volume)
dollar_volumes = []
for d in recent_20:
vol = d.get("volume", 0)
close = d.get("close", 0)
if vol > 0 and close > 0:
dollar_volumes.append(vol * close)
if dollar_volumes:
adv20 = sum(dollar_volumes) / len(dollar_volumes)
else:
adv20 = avg_volume * current_price
result["adv20_dollar"] = round(adv20, 0)
# Check individual thresholds
passes_adv20 = adv20 >= 25_000_000
passes_volume = avg_volume >= 1_000_000
passes_price = current_price >= 10.0
passes_count = sum([passes_adv20, passes_volume, passes_price])
result["passes_all"] = passes_count == 3
# Score
if passes_count == 3:
if adv20 > 100_000_000:
result["score"] = 100.0
elif adv20 > 50_000_000:
result["score"] = 85.0
else:
result["score"] = 70.0
elif passes_count == 2:
result["score"] = 40.0
else:
result["score"] = 15.0
return result
#!/usr/bin/env python3
"""
Risk/Reward Calculator for PEAD Screener
Calculates risk/reward metrics for PEAD trade setups.
Entry: current_price (or slightly above red candle high)
Stop: red_candle['low']
Target: entry + (entry - stop) * target_multiplier
Scoring (20% weight in composite):
- R:R >= 3.0: 100
- R:R >= 2.5: 85
- R:R >= 2.0: 70
- R:R >= 1.5: 50
- R:R < 1.5: 25
"""
def calculate_risk_reward(
current_price: float,
red_candle: dict,
target_multiplier: float = 2.0,
) -> dict:
"""
Calculate risk/reward for a PEAD trade.
Args:
current_price: Current stock price (entry level)
red_candle: Red candle dict with 'high' and 'low' keys
target_multiplier: Target as multiple of risk (default: 2.0)
Returns:
{
"entry_price": float,
"stop_price": float,
"target_price": float,
"risk_pct": float,
"reward_pct": float,
"risk_reward_ratio": float,
"score": float # 0-100
}
"""
result = {
"entry_price": current_price,
"stop_price": 0.0,
"target_price": 0.0,
"risk_pct": 0.0,
"reward_pct": 0.0,
"risk_reward_ratio": 0.0,
"score": 25.0,
}
if not red_candle or current_price <= 0:
return result
stop_price = red_candle["low"]
if stop_price <= 0 or stop_price >= current_price:
return result
# Calculate risk and reward
risk = current_price - stop_price
reward = risk * target_multiplier
target_price = current_price + reward
risk_pct = (risk / current_price) * 100
reward_pct = (reward / current_price) * 100
# Risk/Reward ratio
rr_ratio = reward / risk if risk > 0 else 0.0
result["entry_price"] = round(current_price, 2)
result["stop_price"] = round(stop_price, 2)
result["target_price"] = round(target_price, 2)
result["risk_pct"] = round(risk_pct, 2)
result["reward_pct"] = round(reward_pct, 2)
result["risk_reward_ratio"] = round(rr_ratio, 2)
# Score based on R:R ratio
if rr_ratio >= 3.0:
result["score"] = 100.0
elif rr_ratio >= 2.5:
result["score"] = 85.0
elif rr_ratio >= 2.0:
result["score"] = 70.0
elif rr_ratio >= 1.5:
result["score"] = 50.0
else:
result["score"] = 25.0
return result
#!/usr/bin/env python3
"""
Weekly Candle Calculator for PEAD Screener
Converts daily OHLCV prices to weekly candles using ISO week (Monday start)
and analyzes patterns for Post-Earnings Announcement Drift (PEAD) setups.
Key Features:
- ISO week grouping (Monday-start weeks)
- Earnings week splitting (only post-earnings days)
- Partial week detection
- Red candle identification for pullback entry
- Stage-based pattern classification
"""
from datetime import date, datetime, timedelta
from typing import Optional
def daily_to_weekly(daily_prices: list[dict], earnings_date: str = None) -> list[dict]:
"""
Convert daily OHLCV to weekly candles using ISO week (Monday start).
Args:
daily_prices: Most-recent-first list of dicts with date, open, high,
low, close, volume
earnings_date: If provided, split earnings week (only use post-earnings days)
Rules:
- Group by ISO week: date.isocalendar() -> (year, week)
- Earnings week: only include days on/after earnings_date
- Current (partial) week: mark with partial_week=True
- Weekly OHLC: open=first_day_open, high=max(highs), low=min(lows),
close=last_day_close
- Weekly volume: sum of daily volumes
Returns:
List of weekly candle dicts (most-recent-first):
{
"week_start": "YYYY-MM-DD", # Monday date
"year": int, "week": int,
"open": float, "high": float, "low": float, "close": float,
"volume": int,
"is_green": bool, # close >= open
"partial_week": bool,
"trading_days": int
}
"""
if not daily_prices:
return []
# Parse earnings date if provided
earnings_dt = None
if earnings_date:
earnings_dt = _parse_date(earnings_date)
# Sort daily prices chronologically (oldest first) for grouping
sorted_prices = sorted(daily_prices, key=lambda p: p["date"])
# Group by ISO week
week_groups = {}
for day in sorted_prices:
day_dt = _parse_date(day["date"])
iso_year, iso_week, _ = day_dt.isocalendar()
week_key = (iso_year, iso_week)
# Earnings week filter: only include days on or after earnings_date
if earnings_dt:
earnings_iso = earnings_dt.isocalendar()
earnings_week_key = (earnings_iso[0], earnings_iso[1])
if week_key == earnings_week_key and day_dt < earnings_dt:
continue
if week_key not in week_groups:
week_groups[week_key] = []
week_groups[week_key].append(day)
# Determine current (most recent) week for partial detection
if sorted_prices:
latest_dt = _parse_date(sorted_prices[-1]["date"])
latest_iso = latest_dt.isocalendar()
latest_week_key = (latest_iso[0], latest_iso[1])
# Check if the latest week is complete (has Friday data)
# A week is partial if the latest day is not Friday (weekday 5 in isocalendar)
is_latest_partial = latest_iso[2] < 5
else:
latest_week_key = None
is_latest_partial = False
# Build weekly candles
weekly_candles = []
for week_key in sorted(week_groups.keys()):
days = week_groups[week_key]
if not days:
continue
iso_year, iso_week = week_key
# Calculate Monday date for this ISO week
monday = _iso_week_to_monday(iso_year, iso_week)
# OHLCV aggregation
week_open = days[0]["open"]
week_high = max(d["high"] for d in days)
week_low = min(d["low"] for d in days)
week_close = days[-1]["close"]
week_volume = sum(d.get("volume", 0) for d in days)
# Partial week detection
partial = False
if week_key == latest_week_key and is_latest_partial:
partial = True
# Earnings week with filtered days is also partial
if earnings_dt:
earnings_iso = earnings_dt.isocalendar()
earnings_week_key = (earnings_iso[0], earnings_iso[1])
if week_key == earnings_week_key and len(days) < 5:
partial = True
weekly_candles.append(
{
"week_start": monday.strftime("%Y-%m-%d"),
"year": iso_year,
"week": iso_week,
"open": round(week_open, 2),
"high": round(week_high, 2),
"low": round(week_low, 2),
"close": round(week_close, 2),
"volume": week_volume,
"is_green": week_close >= week_open,
"partial_week": partial,
"trading_days": len(days),
}
)
# Return most-recent-first
weekly_candles.reverse()
return weekly_candles
def find_red_candle(weekly_candles: list[dict], earnings_week_idx: int = None) -> Optional[dict]:
"""
Find the most recent red candle (close < open) after earnings week.
Args:
weekly_candles: Most-recent-first list of weekly candle dicts
earnings_week_idx: Index of the earnings week in weekly_candles
(0=most recent). If None, search from index 0.
Returns:
Dict with red candle info or None if not found:
{
"high": float, "low": float, "open": float, "close": float,
"week_start": str, "week_index": int,
"lower_wick_pct": float, # (min(open,close) - low) / (high - low) * 100
"volume_vs_avg": float # this candle volume / avg of surrounding candles
}
"""
if not weekly_candles:
return None
# Start searching from the candle after the current (partial) week
# but before or at the earnings week
start_idx = 0
end_idx = earnings_week_idx if earnings_week_idx is not None else len(weekly_candles) - 1
for i in range(start_idx, min(end_idx, len(weekly_candles))):
candle = weekly_candles[i]
if not candle["is_green"]: # Red candle: close < open
# Calculate lower wick percentage
candle_range = candle["high"] - candle["low"]
if candle_range > 0:
body_low = min(candle["open"], candle["close"])
lower_wick_pct = (body_low - candle["low"]) / candle_range * 100
else:
lower_wick_pct = 0.0
# Calculate volume vs surrounding candles average
surrounding_volumes = []
for j in range(max(0, i - 2), min(len(weekly_candles), i + 3)):
if j != i:
surrounding_volumes.append(weekly_candles[j]["volume"])
if surrounding_volumes:
avg_vol = sum(surrounding_volumes) / len(surrounding_volumes)
volume_vs_avg = candle["volume"] / avg_vol if avg_vol > 0 else 1.0
else:
volume_vs_avg = 1.0
return {
"high": candle["high"],
"low": candle["low"],
"open": candle["open"],
"close": candle["close"],
"week_start": candle["week_start"],
"week_index": i,
"lower_wick_pct": round(lower_wick_pct, 1),
"volume_vs_avg": round(volume_vs_avg, 2),
}
return None
def analyze_weekly_pattern(
weekly_candles: list[dict],
earnings_date: str,
watch_weeks: int = 5,
) -> dict:
"""
Full weekly pattern analysis for PEAD.
Args:
weekly_candles: Most-recent-first list of weekly candle dicts
earnings_date: Earnings announcement date (YYYY-MM-DD)
watch_weeks: Maximum weeks to monitor after earnings (default: 5)
Returns:
{
"weeks_since_earnings": int,
"earnings_week_idx": int or None,
"red_candle": dict or None,
"is_breakout": bool,
"breakout_pct": float,
"stage": str # MONITORING, SIGNAL_READY, BREAKOUT, EXPIRED
}
"""
result = {
"weeks_since_earnings": 0,
"earnings_week_idx": None,
"red_candle": None,
"is_breakout": False,
"breakout_pct": 0.0,
"stage": "MONITORING",
}
if not weekly_candles:
return result
# Find earnings week
earnings_dt = _parse_date(earnings_date)
earnings_iso = earnings_dt.isocalendar()
earnings_week_key = (earnings_iso[0], earnings_iso[1])
earnings_week_idx = None
for i, candle in enumerate(weekly_candles):
if candle["year"] == earnings_week_key[0] and candle["week"] == earnings_week_key[1]:
earnings_week_idx = i
break
result["earnings_week_idx"] = earnings_week_idx
# Calculate weeks since earnings
if earnings_week_idx is not None:
result["weeks_since_earnings"] = earnings_week_idx
else:
# Earnings week not in data; estimate from dates
if weekly_candles:
latest_dt = _parse_date(weekly_candles[0]["week_start"])
delta_days = (latest_dt - earnings_dt).days
result["weeks_since_earnings"] = max(0, delta_days // 7)
# Check expiration
if result["weeks_since_earnings"] > watch_weeks:
result["stage"] = "EXPIRED"
return result
# Find red candle after earnings week
red_candle = find_red_candle(
weekly_candles,
earnings_week_idx=earnings_week_idx,
)
result["red_candle"] = red_candle
if red_candle is None:
result["stage"] = "MONITORING"
return result
# Check for breakout: current (most recent) candle is green and price > red candle high
current_candle = weekly_candles[0]
if current_candle["is_green"] and current_candle["close"] > red_candle["high"]:
result["is_breakout"] = True
result["breakout_pct"] = round(
(current_candle["close"] - red_candle["high"]) / red_candle["high"] * 100, 2
)
result["stage"] = "BREAKOUT"
else:
result["stage"] = "SIGNAL_READY"
return result
def _parse_date(date_str: str) -> date:
"""Parse a date string in YYYY-MM-DD format."""
return datetime.strptime(date_str, "%Y-%m-%d").date()
def _iso_week_to_monday(iso_year: int, iso_week: int) -> date:
"""Convert ISO year and week number to the Monday date of that week."""
# January 4 is always in ISO week 1
jan4 = date(iso_year, 1, 4)
# Find the Monday of week 1
week1_monday = jan4 - timedelta(days=jan4.weekday())
# Add weeks to get target Monday
return week1_monday + timedelta(weeks=iso_week - 1)
#!/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 PEAD 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
- API call budget enforcement
- Batch company profile support
- Earnings calendar and historical price 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_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 = {
"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 ApiCallBudgetExceeded(Exception):
"""Raised when the API call budget has been exhausted."""
pass
class FMPClient:
"""Client for Financial Modeling Prep API with rate limiting, caching, and budget control"""
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, max_api_calls: int = 200):
self.api_key = api_key or os.getenv("FMP_API_KEY")
if not self.api_key:
raise ValueError(
"FMP API key required. Set FMP_API_KEY environment variable "
"or pass api_key parameter."
)
self.session = requests.Session()
self.session.headers.update({"apikey": self.api_key})
self.cache = {}
self.last_call_time = 0
self.rate_limit_reached = False
self.retry_count = 0
self.max_retries = 1
self.api_calls_made = 0
self.max_api_calls = max_api_calls
# 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]:
"""Make a rate-limited GET request with budget enforcement.
Raises:
ApiCallBudgetExceeded: When api_calls_made >= max_api_calls
"""
if self.api_calls_made >= self.max_api_calls:
raise ApiCallBudgetExceeded(
f"API call budget exhausted: {self.api_calls_made}/{self.max_api_calls} calls used"
)
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 == "historical":
if not isinstance(data, dict):
valid = False
shape_issue = "expected dict"
elif "historicalStockList" in data:
# stable batch format -> v3 single format (exact match only)
norm = symbols_str.replace("-", ".")
found = None
for entry in data["historicalStockList"]:
if entry.get("symbol", "").replace("-", ".") == norm:
found = {
"symbol": entry.get("symbol"),
"historical": entry.get("historical", []),
}
break
if found:
self._endpoint_failures[base_url] = 0
return found
valid = False
shape_issue = f"'{symbols_str}' not in historicalStockList"
elif "historical" not in data:
valid = False
shape_issue = "missing 'historical' key"
elif is_single and data.get("symbol"):
if data["symbol"].replace("-", ".") != symbols_str.replace("-", "."):
valid = False
shape_issue = (
f"response symbol '{data['symbol']}' != requested '{symbols_str}'"
)
if valid:
self._endpoint_failures[base_url] = 0
return data
self._record_endpoint_failure(base_url)
self._warn_fallback(base_url, is_last, shape_issue or "unexpected response shape")
return None
def _warn_fallback(self, base_url: str, is_last: bool, reason: Optional[str]) -> None:
"""Emit a WARN line so users see why a non-last endpoint failed and the
client is falling back. No-op when the failing endpoint is the last one
(its error was already printed by _rate_limited_get with quiet=False)."""
if is_last or not reason:
return
print(
f"WARN: {base_url} failed ({reason}); falling back to next endpoint",
file=sys.stderr,
)
def _record_endpoint_failure(self, base_url: str) -> None:
"""Track consecutive failures and disable endpoint after threshold."""
failures = self._endpoint_failures.get(base_url, 0) + 1
self._endpoint_failures[base_url] = failures
if failures >= self._ENDPOINT_FAILURE_THRESHOLD:
self._disabled_endpoints.add(base_url)
def get_earnings_calendar(self, from_date: str, to_date: str) -> Optional[list[dict]]:
"""Fetch earnings calendar for a date range.
Args:
from_date: Start date in YYYY-MM-DD format
to_date: End date in YYYY-MM-DD format
Returns:
List of earnings event dicts or None on failure.
Each dict contains: date, symbol, eps, epsEstimated, revenue,
revenueEstimated, time (bmo/amc)
"""
cache_key = f"earnings_{from_date}_{to_date}"
if cache_key in self.cache:
return self.cache[cache_key]
# Hardcoded v3 URL bypasses the stable→v3 fallback list; rewrite here.
url, params = v3_to_stable(
f"{self.BASE_URL}/earning_calendar", {"from": from_date, "to": to_date}
)
data = self._rate_limited_get(url, params)
if data:
self.cache[cache_key] = data
return data
def get_company_profiles(self, symbols: list[str]) -> dict[str, dict]:
"""Fetch company profiles for multiple symbols.
The /stable profile endpoint does not support comma-batched symbols
(a multi-symbol request returns ``[]``), so fetch one symbol at a time.
Args:
symbols: List of stock symbols
Returns:
Dict mapping symbol -> profile dict (with marketCap, sector, etc.)
"""
results = {}
for symbol in symbols:
cache_key = f"profile_{symbol}"
if cache_key in self.cache:
cached = self.cache[cache_key]
if isinstance(cached, dict):
results[symbol] = cached
continue
# Hardcoded v3 URL bypasses the stable→v3 fallback list; rewrite here.
url, params = v3_to_stable(f"{self.BASE_URL}/profile/{symbol}")
data = self._rate_limited_get(url, params)
if data and isinstance(data, list) and data:
profile = data[0]
if isinstance(profile, dict):
# Preserve the prior lenient behavior: a profile that omits
# "symbol" is still returned under the requested symbol.
self.cache[cache_key] = profile
results[profile.get("symbol", symbol)] = profile
return results
def get_historical_prices(self, symbol: str, days: int = 90) -> 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,
"max_api_calls": self.max_api_calls,
"rate_limit_reached": self.rate_limit_reached,
"budget_remaining": max(0, self.max_api_calls - self.api_calls_made),
}
#!/usr/bin/env python3
"""
PEAD Screener Report Generator
Generates JSON and Markdown reports for PEAD screening results.
Groups results by stage: BREAKOUT first, then SIGNAL_READY, MONITORING, EXPIRED.
Outputs:
- JSON: Structured data for programmatic use
- Markdown: Human-readable report with stage-grouped results
"""
import json
# Stage display order (highest priority first)
STAGE_ORDER = ["BREAKOUT", "SIGNAL_READY", "MONITORING", "EXPIRED"]
STAGE_DESCRIPTIONS = {
"BREAKOUT": "Price has broken above the red candle high on a green weekly candle",
"SIGNAL_READY": "Red candle pullback identified, awaiting breakout",
"MONITORING": "Post-earnings gap-up, no red candle yet",
"EXPIRED": "Beyond the monitoring window",
}
def generate_json_report(
results: list[dict],
metadata: dict,
output_file: str,
) -> None:
"""Generate JSON report with PEAD screening results.
Args:
results: All screening results
metadata: Screening metadata
output_file: Output file path
"""
# Group and sort by stage
sorted_results = _sort_by_stage(results)
# Generate summary
summary = _generate_summary(results)
report = {
"metadata": metadata,
"results": sorted_results,
"summary": summary,
}
with open(output_file, "w") 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,
) -> None:
"""Generate Markdown report with PEAD screening results.
Args:
results: All screening results
metadata: Screening metadata
output_file: Output file path
"""
lines = []
# Header
lines.append("# PEAD Screener Report - Post-Earnings Announcement Drift")
lines.append(f"**Generated:** {metadata.get('generated_at', 'N/A')}")
mode = metadata.get("mode", "A")
mode_desc = "FMP Earnings Calendar" if mode == "A" else "Earnings Trade Analyzer JSON"
lines.append(f"**Mode:** {mode} ({mode_desc})")
lines.append(f"**Lookback:** {metadata.get('lookback_days', 'N/A')} days")
lines.append(f"**Watch Window:** {metadata.get('watch_weeks', 'N/A')} weeks")
lines.append("")
lines.append("---")
lines.append("")
# Summary
summary = _generate_summary(results)
lines.append("## Summary")
lines.append("")
lines.append("| Stage | Count |")
lines.append("|-------|-------|")
lines.append(f"| Total Screened | {summary['total_screened']} |")
lines.append(f"| BREAKOUT | {summary['breakout']} |")
lines.append(f"| SIGNAL_READY | {summary['signal_ready']} |")
lines.append(f"| MONITORING | {summary['monitoring']} |")
lines.append(f"| EXPIRED | {summary['expired']} |")
lines.append("")
lines.append("---")
lines.append("")
# Group results by stage
stage_groups = {}
for stage in STAGE_ORDER:
stage_groups[stage] = [r for r in results if r.get("stage") == stage]
# Render each stage section
for stage in STAGE_ORDER:
group = stage_groups.get(stage, [])
# Sort by composite score within each stage
group.sort(key=lambda x: x.get("composite_score", 0), reverse=True)
stage_desc = STAGE_DESCRIPTIONS.get(stage, "")
count_label = f"{len(group)} stock{'s' if len(group) != 1 else ''}"
lines.append(f"## {stage} ({count_label})")
lines.append(f"*{stage_desc}*")
lines.append("")
if group:
for i, stock in enumerate(group, 1):
lines.extend(_format_stock_entry(i, stock))
else:
lines.append("No stocks in this stage.")
lines.append("")
lines.append("---")
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"- **Budget Remaining:** {api_stats.get('budget_remaining', '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 identifies Post-Earnings Announcement Drift (PEAD) setups:")
lines.append("")
lines.append("1. **Setup Quality** (30%) - Earnings gap quality and weekly pattern formation")
lines.append(
"2. **Breakout Strength** (25%) - Price breakout above red candle high with volume"
)
lines.append("3. **Liquidity** (25%) - ADV20, average volume, and price thresholds")
lines.append("4. **Risk/Reward** (20%) - Entry/stop/target risk-reward ratio")
lines.append("")
lines.append(
"For detailed methodology, see the PEAD strategy reference in the pead-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") 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 with rating indicator
rating = stock.get("rating", "N/A")
indicator = _rating_indicator(stock.get("composite_score", 0))
symbol = stock.get("symbol", "???")
stage = stock.get("stage", "UNKNOWN")
lines.append(f"### {rank}. {symbol} {indicator}")
# Basic info
price = stock.get("current_price", 0) or 0
earnings_date = stock.get("earnings_date", "N/A")
gap_pct = stock.get("gap_pct", 0) or 0
timing = stock.get("earnings_timing", "N/A")
weeks = stock.get("weeks_since_earnings", 0)
lines.append(
f"**Price:** ${price:.2f} | **Earnings:** {earnings_date} ({timing}) | "
f"**Gap:** {gap_pct:+.1f}% | **Weeks Since:** {weeks}"
)
# Composite score
lines.append(f"**PEAD Score:** {stock.get('composite_score', 0):.1f}/100 ({rating})")
lines.append("")
# Component breakdown table
components = stock.get("components", {})
if components:
lines.append("| Component | Score | Weight | Weighted |")
lines.append("|-----------|-------|--------|----------|")
for key, comp in components.items():
label = comp.get("label", key)
score = comp.get("score", 0)
weight = comp.get("weight", 0)
weighted = comp.get("weighted", 0)
lines.append(f"| {label} | {score:.0f} | {weight:.0%} | {weighted:.1f} |")
lines.append("")
# Red candle info
red_candle = stock.get("red_candle")
if red_candle:
rc_high = red_candle.get("high", 0)
rc_low = red_candle.get("low", 0)
rc_week = red_candle.get("week_start", "N/A")
lines.append(
f"**Red Candle:** Week of {rc_week} | High: ${rc_high:.2f} | Low: ${rc_low:.2f}"
)
else:
lines.append("**Red Candle:** Not yet formed")
# Trade setup
lines.append("")
lines.append("**Trade Setup:**")
if stage == "BREAKOUT":
entry = stock.get("entry_price", price)
stop = stock.get("stop_price", 0)
target = stock.get("target_price", 0)
rr = stock.get("risk_reward_ratio", 0)
breakout_pct = stock.get("breakout_pct", 0)
lines.append(f"- Entry: ${entry:.2f} (breakout +{breakout_pct:.1f}% above red candle high)")
lines.append(f"- Stop: ${stop:.2f} (red candle low)")
lines.append(f"- Target: ${target:.2f} (2R)")
lines.append(f"- Risk/Reward: {rr:.1f}:1")
elif stage == "SIGNAL_READY":
if red_candle:
lines.append(f"- Trigger: Close above ${red_candle['high']:.2f} on green weekly candle")
lines.append(f"- Stop (if triggered): ${red_candle['low']:.2f}")
lines.append("- Action: Set alert at red candle high")
elif stage == "MONITORING":
lines.append("- Action: Monitor for red candle formation")
else:
lines.append("- Action: Remove from watchlist (expired)")
guidance = stock.get("guidance", "N/A")
lines.append(f"- Guidance: {guidance}")
lines.append("")
lines.append("---")
lines.append("")
return lines
def _rating_indicator(score: float) -> str:
"""Get indicator for rating."""
if score >= 85:
return "[STRONG]"
elif score >= 70:
return "[GOOD]"
elif score >= 55:
return "[DEVELOPING]"
else:
return ""
def _sort_by_stage(results: list[dict]) -> list[dict]:
"""Sort results by stage priority, then by composite score within each stage."""
stage_priority = {stage: i for i, stage in enumerate(STAGE_ORDER)}
def sort_key(r):
stage = r.get("stage", "EXPIRED")
priority = stage_priority.get(stage, len(STAGE_ORDER))
score = r.get("composite_score", 0)
return (priority, -score)
return sorted(results, key=sort_key)
def _generate_summary(results: list[dict]) -> dict:
"""Generate summary statistics from results."""
total = len(results)
breakout = sum(1 for r in results if r.get("stage") == "BREAKOUT")
signal_ready = sum(1 for r in results if r.get("stage") == "SIGNAL_READY")
monitoring = sum(1 for r in results if r.get("stage") == "MONITORING")
expired = sum(1 for r in results if r.get("stage") == "EXPIRED")
return {
"total_screened": total,
"breakout": breakout,
"signal_ready": signal_ready,
"monitoring": monitoring,
"expired": expired,
}
#!/usr/bin/env python3
"""
PEAD Screener - 4-Component Composite Scoring Engine
Combines component scores into a weighted composite (0-100).
Component Weights:
1. Setup Quality: 30%
2. Breakout Strength: 25%
3. Liquidity: 25%
4. Risk/Reward: 20%
Total: 100%
Rating Bands:
85-100: Strong Setup - High-conviction PEAD trade, full position size
70-84: Good Setup - Solid PEAD setup, standard position size
55-69: Developing - Watchlist, wait for cleaner breakout
<55: Weak - Not actionable
"""
COMPONENT_WEIGHTS = {
"setup_quality": 0.30,
"breakout_strength": 0.25,
"liquidity": 0.25,
"risk_reward": 0.20,
}
COMPONENT_LABELS = {
"setup_quality": "Setup Quality",
"breakout_strength": "Breakout Strength",
"liquidity": "Liquidity",
"risk_reward": "Risk/Reward",
}
def calculate_composite_score(
setup_score: float,
breakout_score: float,
liquidity_score: float,
rr_score: float,
) -> dict:
"""
Calculate weighted composite PEAD score.
Args:
setup_score: Setup Quality score (0-100)
breakout_score: Breakout Strength score (0-100)
liquidity_score: Liquidity score (0-100)
rr_score: Risk/Reward score (0-100)
Returns:
Dict with composite_score, rating, guidance, component breakdown
"""
component_scores = {
"setup_quality": setup_score,
"breakout_strength": breakout_score,
"liquidity": liquidity_score,
"risk_reward": rr_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
rating_info = _get_rating(composite)
return {
"composite_score": composite,
"rating": rating_info["rating"],
"rating_description": rating_info["description"],
"guidance": rating_info["guidance"],
"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(composite: float) -> dict:
"""Map composite score to rating and guidance."""
if composite >= 85:
return {
"rating": "Strong Setup",
"description": "High-conviction PEAD trade with all components aligned",
"guidance": "High-conviction PEAD trade, full position size",
}
elif composite >= 70:
return {
"rating": "Good Setup",
"description": "Solid PEAD setup with minor imperfections",
"guidance": "Solid PEAD setup, standard position size",
}
elif composite >= 55:
return {
"rating": "Developing",
"description": "PEAD pattern forming but not yet fully actionable",
"guidance": "Watchlist, wait for cleaner breakout",
}
else:
return {
"rating": "Weak",
"description": "Insufficient PEAD characteristics for trading",
"guidance": "Not actionable",
}
#!/usr/bin/env python3
"""
PEAD Stock Screener - Main Orchestrator
Screens post-earnings gap-up stocks for Post-Earnings Announcement Drift (PEAD)
patterns using weekly candle analysis.
Two input modes:
Mode A: FMP earnings calendar -> profile batch -> gap filter -> weekly analysis
Mode B: earnings-trade-analyzer JSON output -> grade filter -> weekly analysis
Usage:
# Mode A: FMP earnings calendar (default)
python3 screen_pead.py --api-key YOUR_KEY --output-dir reports/
# Mode B: From earnings-trade-analyzer JSON
python3 screen_pead.py --candidates-json reports/earnings_analysis.json --output-dir reports/
Output:
- JSON: pead_screener_YYYY-MM-DD_HHMMSS.json
- Markdown: pead_screener_YYYY-MM-DD_HHMMSS.md
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timedelta
from typing import Optional
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(__file__))
from calculators.breakout_calculator import calculate_breakout
from calculators.liquidity_calculator import calculate_liquidity
from calculators.risk_reward_calculator import calculate_risk_reward
from calculators.weekly_candle_calculator import analyze_weekly_pattern, daily_to_weekly
from fmp_client import ApiCallBudgetExceeded, FMPClient
from report_generator import generate_json_report, generate_markdown_report
from scorer import calculate_composite_score
logger = logging.getLogger(__name__)
def calculate_price_gap(daily_prices: list[dict], earnings_date: str, timing: str) -> float:
"""Calculate actual price gap from daily OHLCV data.
BMO: gap = (open[earnings_date] / close[prev_day]) - 1
AMC/unknown: gap = (open[next_day] / close[earnings_date]) - 1
Args:
daily_prices: Most-recent-first daily price data
earnings_date: YYYY-MM-DD string
timing: 'bmo', 'amc', or empty/unknown
Returns:
Gap percentage (e.g. 6.3 for 6.3%), or 0.0 if calculation not possible.
"""
# Find earnings date index
earnings_idx = -1
for i, bar in enumerate(daily_prices):
if bar.get("date") == earnings_date:
earnings_idx = i
break
if earnings_idx == -1:
return 0.0
timing_lower = (timing or "").lower().strip()
if timing_lower == "bmo":
# BMO: gap = open[earnings_date] / close[prev_day] - 1
prev_idx = earnings_idx + 1 # most-recent-first
if prev_idx >= len(daily_prices):
return 0.0
base_price = daily_prices[prev_idx].get("close", 0)
gap_price = daily_prices[earnings_idx].get("open", 0)
else:
# AMC or unknown: gap = open[next_day] / close[earnings_date] - 1
next_idx = earnings_idx - 1 # most-recent-first
if next_idx < 0:
return 0.0
base_price = daily_prices[earnings_idx].get("close", 0)
gap_price = daily_prices[next_idx].get("open", 0)
if not base_price:
return 0.0
return round(((gap_price / base_price) - 1.0) * 100.0, 2)
def parse_arguments():
parser = argparse.ArgumentParser(
description="PEAD Stock Screener - Post-Earnings Announcement Drift"
)
# Common arguments
parser.add_argument(
"--api-key", help="FMP API key (defaults to FMP_API_KEY environment variable)"
)
parser.add_argument(
"--watch-weeks",
type=int,
default=5,
help="Monitoring period in weeks after earnings (default: 5)",
)
parser.add_argument(
"--max-api-calls",
type=int,
default=200,
help="API call budget (default: 200)",
)
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 (default: reports/)",
)
# Mode A arguments
parser.add_argument(
"--lookback-days",
type=int,
default=14,
help="Days back for earnings calendar (Mode A, default: 14)",
)
parser.add_argument(
"--min-gap",
type=float,
default=3.0,
help="Minimum earnings gap %% (Mode A, default: 3.0)",
)
parser.add_argument(
"--min-market-cap",
type=float,
default=500_000_000,
help="Minimum market cap (Mode A, default: 500000000)",
)
# Mode B arguments
parser.add_argument(
"--candidates-json",
help="Path to earnings-trade-analyzer JSON output (Mode B)",
)
parser.add_argument(
"--min-grade",
default="B",
choices=["A", "B", "C", "D"],
help="Minimum grade filter (Mode B, default: B)",
)
return parser.parse_args()
def validate_input_json(data: dict) -> list[dict]:
"""
Validate earnings-trade-analyzer JSON output for Mode B.
Checks:
1. schema_version == "1.0" -> ValueError if mismatch
2. 'results' key exists and is a list
3. Each result has required fields: symbol, earnings_date, earnings_timing, gap_pct, grade
4. Missing fields -> warn + skip that record (don't abort unless ALL fail)
Returns:
Validated list of result dicts.
Raises:
ValueError: If schema_version != "1.0" or all records are invalid
"""
# Check schema version
schema_version = data.get("schema_version", "")
if schema_version != "1.0":
raise ValueError(
f"Schema version mismatch: expected '1.0', got '{schema_version}'. "
"This input may be from an incompatible version of earnings-trade-analyzer."
)
# Check results key
results = data.get("results")
if not isinstance(results, list):
raise ValueError("Input JSON missing 'results' key or 'results' is not a list")
required_fields = ["symbol", "earnings_date", "earnings_timing", "gap_pct", "grade"]
valid_timings = {"bmo", "amc", "unknown"}
valid_grades = {"A", "B", "C", "D"}
validated = []
for i, record in enumerate(results):
# Check required field existence
missing = [f for f in required_fields if f not in record]
if missing:
logger.warning(
"Skipping record %d: missing required fields %s (has keys: %s)",
i,
missing,
list(record.keys()),
)
continue
# Type and value range validation
errors = []
if not isinstance(record["symbol"], str) or not record["symbol"].strip():
errors.append("symbol must be a non-empty string")
if not isinstance(record["earnings_date"], str) or len(record["earnings_date"]) != 10:
errors.append("earnings_date must be YYYY-MM-DD string")
if record["earnings_timing"] not in valid_timings:
errors.append(f"earnings_timing '{record['earnings_timing']}' not in {valid_timings}")
if not isinstance(record["gap_pct"], (int, float)):
errors.append(f"gap_pct must be numeric, got {type(record['gap_pct']).__name__}")
if record["grade"] not in valid_grades:
errors.append(f"grade '{record['grade']}' not in {valid_grades}")
if errors:
logger.warning(
"Skipping record %d (%s): %s",
i,
record.get("symbol", "?"),
"; ".join(errors),
)
continue
validated.append(record)
if not validated:
raise ValueError(
f"All {len(results)} records failed validation. No valid candidates to process."
)
return validated
def calculate_setup_quality(gap_pct: float, pattern_result: dict) -> float:
"""Calculate setup quality score based on earnings gap and pattern.
Args:
gap_pct: Earnings gap percentage
pattern_result: Result from analyze_weekly_pattern()
Returns:
Setup quality score (0-100)
"""
score = 0.0
# Gap quality (0-50 points)
if gap_pct >= 10.0:
score += 50
elif gap_pct >= 7.0:
score += 40
elif gap_pct >= 5.0:
score += 30
elif gap_pct >= 3.0:
score += 20
else:
score += 10
# Pattern quality (0-50 points)
stage = pattern_result.get("stage", "MONITORING")
weeks = pattern_result.get("weeks_since_earnings", 0)
red_candle = pattern_result.get("red_candle")
if stage == "BREAKOUT":
score += 50
elif stage == "SIGNAL_READY":
score += 40
# Bonus for red candle with long lower wick (institutional support)
if red_candle and red_candle.get("lower_wick_pct", 0) > 30:
score += 5
elif stage == "MONITORING":
# Earlier in the cycle is better
if weeks <= 2:
score += 25
else:
score += 15
else: # EXPIRED
score += 0
return min(100.0, score)
def analyze_stock(
symbol: str,
daily_prices: list[dict],
earnings_date: str,
earnings_timing: str,
gap_pct: float,
current_price: float,
watch_weeks: int = 5,
) -> Optional[dict]:
"""
Full PEAD analysis for a single stock.
Args:
symbol: Stock symbol
daily_prices: Most-recent-first daily OHLCV data
earnings_date: Earnings announcement date (YYYY-MM-DD)
earnings_timing: 'bmo' (before market open) or 'amc' (after market close)
gap_pct: Earnings gap percentage
current_price: Current stock price
watch_weeks: Maximum monitoring window in weeks
Returns:
Analysis result dict or None on failure
"""
if not daily_prices or len(daily_prices) < 5:
return None
# 1. Convert to weekly candles
weekly_candles = daily_to_weekly(daily_prices, earnings_date=earnings_date)
if not weekly_candles:
return None
# 2. Analyze weekly pattern
pattern = analyze_weekly_pattern(weekly_candles, earnings_date, watch_weeks=watch_weeks)
# 3. Calculate setup quality
setup_score = calculate_setup_quality(gap_pct, pattern)
# 4. Calculate breakout
red_candle = pattern.get("red_candle")
if red_candle:
breakout = calculate_breakout(weekly_candles, red_candle, current_price)
else:
breakout = {
"is_breakout": False,
"breakout_pct": 0.0,
"volume_confirmation": False,
"score": 0.0,
}
# 5. Calculate liquidity
liquidity = calculate_liquidity(daily_prices, current_price)
# 6. Calculate risk/reward
if red_candle:
rr = calculate_risk_reward(current_price, red_candle)
else:
rr = {
"entry_price": current_price,
"stop_price": 0.0,
"target_price": 0.0,
"risk_pct": 0.0,
"reward_pct": 0.0,
"risk_reward_ratio": 0.0,
"score": 25.0,
}
# 7. Composite score
composite = calculate_composite_score(
setup_score=setup_score,
breakout_score=breakout["score"],
liquidity_score=liquidity["score"],
rr_score=rr["score"],
)
return {
"symbol": symbol,
"stage": pattern["stage"],
"earnings_date": earnings_date,
"earnings_timing": earnings_timing,
"gap_pct": gap_pct,
"weeks_since_earnings": pattern["weeks_since_earnings"],
"red_candle": red_candle,
"current_price": current_price,
"breakout_pct": breakout["breakout_pct"],
"entry_price": rr["entry_price"],
"stop_price": rr["stop_price"],
"target_price": rr["target_price"],
"risk_pct": rr["risk_pct"],
"risk_reward_ratio": rr["risk_reward_ratio"],
"adv20_dollar": liquidity["adv20_dollar"],
"composite_score": composite["composite_score"],
"rating": composite["rating"],
"guidance": composite["guidance"],
"components": composite["component_breakdown"],
}
def main():
args = parse_arguments()
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s: %(message)s",
)
print("=" * 70)
print("PEAD Stock Screener")
print("Post-Earnings Announcement Drift")
print("=" * 70)
print()
# Determine mode
mode = "B" if args.candidates_json else "A"
print(f"Mode: {mode} ({'JSON Input' if mode == 'B' else 'FMP Earnings Calendar'})")
# Initialize FMP client (needed for both modes for historical data)
try:
client = FMPClient(api_key=args.api_key, max_api_calls=args.max_api_calls)
print("FMP API client initialized")
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# ========================================================================
# Phase 1: Get Candidates
# ========================================================================
print()
print("Phase 1: Get Candidates")
print("-" * 70)
candidates = []
if mode == "A":
candidates = _get_candidates_mode_a(client, args)
else:
candidates = _get_candidates_mode_b(args)
if not candidates:
print(" No candidates found. Exiting.")
sys.exit(0)
print(f" Total candidates: {len(candidates)}")
print()
# ========================================================================
# Phase 1.5: Budget Check
# ========================================================================
print("Phase 1.5: Budget Check")
print("-" * 70)
api_stats = client.get_api_stats()
remaining = api_stats["budget_remaining"]
needed = len(candidates) # 1 historical call per candidate
print(f" API calls remaining: {remaining}")
print(f" Estimated calls needed: {needed} (1 per candidate)")
if needed > remaining:
# Trim candidates to fit budget
candidates = candidates[:remaining]
print(f" WARNING: Trimmed to {len(candidates)} candidates to fit API budget")
else:
print(" Budget sufficient")
print()
# ========================================================================
# Phase 2: Fetch Historical Data & Weekly Candle Analysis
# ========================================================================
print("Phase 2: Fetch Historical Data")
print("-" * 70)
results = []
for i, candidate in enumerate(candidates):
symbol = candidate["symbol"]
if (i + 1) % 10 == 0 or i == len(candidates) - 1:
print(f" Progress: {i + 1}/{len(candidates)}", flush=True)
try:
data = client.get_historical_prices(symbol, days=90)
except ApiCallBudgetExceeded:
print(f" WARNING: API budget exceeded at {symbol}. Processing collected data.")
break
if not data or "historical" not in data:
continue
daily_prices = data["historical"]
if not daily_prices:
continue
current_price = daily_prices[0].get("close", 0)
if current_price <= 0:
continue
# Calculate actual price gap if not already provided (Mode A)
gap_pct = candidate.get("gap_pct")
if gap_pct is None:
gap_pct = calculate_price_gap(
daily_prices,
candidate["earnings_date"],
candidate.get("earnings_timing", ""),
)
# Apply min-gap filter (using actual price gap, not EPS estimate)
if mode == "A" and abs(gap_pct) < args.min_gap:
continue
# Run analysis
analysis = analyze_stock(
symbol=symbol,
daily_prices=daily_prices,
earnings_date=candidate["earnings_date"],
earnings_timing=candidate.get("earnings_timing", ""),
gap_pct=gap_pct,
current_price=current_price,
watch_weeks=args.watch_weeks,
)
if analysis:
print(
f" {symbol:6} Stage: {analysis['stage']:14} "
f"Score: {analysis['composite_score']:5.1f} ({analysis['rating']})"
)
results.append(analysis)
print()
# ========================================================================
# Phase 3: Score & Report
# ========================================================================
print("Phase 3: Generate Reports")
print("-" * 70)
# Create output directory if needed
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"pead_screener_{timestamp}.json")
md_file = os.path.join(args.output_dir, f"pead_screener_{timestamp}.md")
api_stats = client.get_api_stats()
metadata = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"lookback_days": args.lookback_days if mode == "A" else None,
"watch_weeks": args.watch_weeks,
"mode": mode,
"input_file": args.candidates_json if mode == "B" else None,
"min_gap": args.min_gap if mode == "A" else None,
"min_market_cap": args.min_market_cap if mode == "A" else None,
"min_grade": args.min_grade if mode == "B" else None,
"api_stats": api_stats,
}
# Sort by stage priority then composite score before top-N cutoff
stage_priority = {"BREAKOUT": 0, "SIGNAL_READY": 1, "MONITORING": 2, "EXPIRED": 3}
results.sort(
key=lambda r: (stage_priority.get(r["stage"], 9), -r["composite_score"]),
)
top_results = results[: args.top] if len(results) > args.top else results
generate_json_report(top_results, metadata, json_file)
generate_markdown_report(top_results, metadata, md_file)
# ========================================================================
# Summary
# ========================================================================
print()
print("=" * 70)
print("PEAD Screening Complete")
print("=" * 70)
# Stage counts
stage_counts = {}
for r in results:
stage = r.get("stage", "UNKNOWN")
stage_counts[stage] = stage_counts.get(stage, 0) + 1
print()
print("Stage Distribution:")
for stage in ["BREAKOUT", "SIGNAL_READY", "MONITORING", "EXPIRED"]:
count = stage_counts.get(stage, 0)
print(f" {stage:14} {count}")
# Top 5
if results:
print()
print(f"Top {min(5, len(results))} Results:")
# Sort by stage priority then score
stage_priority = {"BREAKOUT": 0, "SIGNAL_READY": 1, "MONITORING": 2, "EXPIRED": 3}
sorted_results = sorted(
results,
key=lambda r: (stage_priority.get(r["stage"], 9), -r["composite_score"]),
)
for i, r in enumerate(sorted_results[:5], 1):
print(
f" {i}. {r['symbol']:6} {r['stage']:14} "
f"Score: {r['composite_score']:5.1f} ({r['rating']})"
)
else:
print()
print(" No PEAD candidates found.")
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" Budget remaining: {api_stats['budget_remaining']}")
print()
def _get_candidates_mode_a(client: FMPClient, args) -> list[dict]:
"""Get candidates from FMP earnings calendar (Mode A)."""
# Calculate date range
to_date = datetime.now().strftime("%Y-%m-%d")
from_date = (datetime.now() - timedelta(days=args.lookback_days)).strftime("%Y-%m-%d")
print(f" Fetching earnings calendar: {from_date} to {to_date}")
earnings = client.get_earnings_calendar(from_date, to_date)
if not earnings:
print(" WARNING: No earnings data returned")
return []
print(f" Raw earnings events: {len(earnings)}")
# Get unique symbols
symbols = list(set(e.get("symbol", "") for e in earnings if e.get("symbol")))
if not symbols:
return []
# Fetch company profiles for market cap filtering
print(f" Fetching profiles for {len(symbols)} symbols...")
profiles = client.get_company_profiles(symbols)
# Build candidates with market cap filter (gap filter deferred to Phase 2
# where actual price data is available for accurate gap calculation)
grade_map = {e.get("symbol"): e for e in earnings}
candidates = []
for symbol in symbols:
earning = grade_map.get(symbol, {})
profile = profiles.get(symbol, {})
# Market cap filter
market_cap = profile.get("mktCap", 0) or 0
if market_cap < args.min_market_cap:
continue
timing = earning.get("time", "")
# Normalize timing
if timing in ("bmo", "Before Market Open"):
timing = "bmo"
elif timing in ("amc", "After Market Close"):
timing = "amc"
candidates.append(
{
"symbol": symbol,
"earnings_date": earning.get("date", ""),
"earnings_timing": timing,
"gap_pct": None, # Calculated from price data in Phase 2
"market_cap": market_cap,
}
)
print(f" Candidates after market cap filter: {len(candidates)}")
return candidates
def _get_candidates_mode_b(args) -> list[dict]:
"""Get candidates from earnings-trade-analyzer JSON (Mode B).
Raises:
SystemExit(1): On file not found, JSON parse error, or validation error.
"""
json_path = args.candidates_json
print(f" Loading: {json_path}")
if not os.path.exists(json_path):
print(f" ERROR: File not found: {json_path}", file=sys.stderr)
sys.exit(1)
with open(json_path) as f:
data = json.load(f)
# Validation errors are fatal in Mode B (bad input should not silently succeed)
validated = validate_input_json(data)
print(f" Validated records: {len(validated)}")
# Grade filter
grade_order = {"A": 0, "B": 1, "C": 2, "D": 3}
min_grade_rank = grade_order.get(args.min_grade, 1)
candidates = []
for record in validated:
grade = record.get("grade", "D")
grade_rank = grade_order.get(grade, 3)
if grade_rank <= min_grade_rank:
candidates.append(
{
"symbol": record["symbol"],
"earnings_date": record["earnings_date"],
"earnings_timing": record.get("earnings_timing", ""),
"gap_pct": record.get("gap_pct", 0),
"grade": grade,
}
)
print(f" After grade filter (>= {args.min_grade}): {len(candidates)}")
return candidates
if __name__ == "__main__":
main()
"""Shared fixtures for PEAD Screener tests"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.dirname(__file__))
Related skills
How it compares
Use pead-screener for earnings-gap PEAD pattern scans; use edge-candidate-agent when observations must become formal pipeline YAML candidates.
FAQ
What inputs does pead-screener accept?
pead-screener supports Mode A from the FMP earnings calendar and Mode B from earnings-trade-analyzer JSON, then scans gap-up names for PEAD patterns via weekly candle analysis.
What signals does pead-screener detect?
pead-screener flags Post-Earnings Announcement Drift setups by reading weekly formations for red-candle pullbacks and breakout signals following earnings gap-up moves.