
Earnings Trade Analyzer
- 900 installs
- 2.5k repo stars
- Updated July 26, 2026
- tradermonty/claude-trading-skills
earnings-trade-analyzer is a Python agent skill that scores post-earnings stock setups with a five-factor weighted model from 0–100 and letter grades for developers who automate earnings momentum screening.
About
earnings-trade-analyzer is an agent skill centered on analyze_earnings_trades.py, which pulls recent earnings reactions via the FMP API and scores each stock using five weighted factors: gap size at 25%, pre-earnings 20-day trend at 30%, volume trend at 20%, MA200 position at 15%, and MA50 position at 10%. Each candidate receives a composite score from 0–100 and a letter grade from A through D, with Grade A at 85+ signaling strong institutional accumulation. Default screening uses a 2-day lookback and top 20 results, sufficient on FMP free tier at 250 calls per day. Developers reach for earnings-trade-analyzer when building agent workflows for post-earnings momentum, PEAD candidate discovery, or scheduled after-close earnings reaction reviews.
- 5-factor weighted composite score from 0–100 with letter grades A/B/C/D
- Gap size factor (25% weight) with BMO vs AMC timing logic for earnings gaps
- Pre-earnings 20-day return trend factor (30% weight) with tiered scoring table
- Volume trend factor (20% weight) using 20-day vs 60-day average volume ratio
- Documented score bands from >=10% gap (100) down to <1% gap (15)
Earnings Trade Analyzer by the numbers
- 900 all-time installs (skills.sh)
- +50 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #161 of 1,136 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill earnings-trade-analyzerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 900 |
|---|---|
| repo stars | ★ 2.5k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 26, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you score post-earnings stock momentum setups?
Score post-earnings stock setups with a weighted five-factor model and letter grades before committing capital.
Who is it for?
Developers automating post-earnings trade research who need reproducible five-factor scoring and letter grades from FMP earnings calendar data.
Skip if: Long-term fundamental investors who ignore earnings gaps, volume trends, and short-horizon momentum signals entirely.
When should I use this skill?
A developer asks for post-earnings trade analysis, earnings gap scoring, PEAD screening, or earnings momentum grading.
What you get
Timestamped JSON analyzer report, Markdown summary tables, composite scores, and A/B/C/D letter grades per ticker.
- earnings_trade_analyzer JSON report
- earnings_trade_analyzer Markdown report
By the numbers
- Uses a 5-factor weighted scoring system producing 0–100 composite scores
- Default screening uses 2-day lookback returning top 20 candidates
- Letter grade A requires composite score of 85 or higher
Files
Earnings Trade Analyzer - Post-Earnings 5-Factor Scoring
Analyze recent post-earnings stocks using a 5-factor weighted scoring system to identify the strongest earnings reactions for potential momentum trades.
When to Use
- User asks for post-earnings trade analysis or earnings gap screening
- User wants to find the best recent earnings reactions
- User requests earnings momentum scoring or grading
- User asks about post-earnings accumulation day (PEAD) candidates
Prerequisites
- FMP API key (set
FMP_API_KEYenvironment variable or pass--api-key) - Free tier (250 calls/day) is sufficient for default screening (lookback 2 days, top 20)
- Paid tier recommended for larger lookback windows or full screening
Workflow
Step 1: Run the Earnings Trade Analyzer
Execute the analyzer script:
# Default: last 2 days of earnings, top 20 results
python3 skills/earnings-trade-analyzer/scripts/analyze_earnings_trades.py --output-dir reports/
# Custom lookback and market cap filter
python3 skills/earnings-trade-analyzer/scripts/analyze_earnings_trades.py \
--lookback-days 5 \
--min-market-cap 1000000000 \
--top 30 \
--output-dir reports/
# With entry quality filter
python3 skills/earnings-trade-analyzer/scripts/analyze_earnings_trades.py \
--apply-entry-filter \
--output-dir reports/Degraded endpoint / budget fallback for scheduled reviews
If the analyzer reports a 404, an implausible empty earnings calendar, or exhausts its API-call budget before producing scored candidates during a scheduled after-close/pre-market run, do not report "no earnings reactions" immediately.
1. First retry once with a narrower liquid-universe configuration so the full 5-factor scorer has a chance to complete, for example:
python3 skills/earnings-trade-analyzer/scripts/analyze_earnings_trades.py \
--lookback-days 2 \
--min-market-cap 5000000000 \
--top 20 \
--max-api-calls 600 \
--output-dir reports/<routine-date>2. If the scored run still returns no candidates or cannot complete, verify the same range through the stable endpoint used by the compatibility shim and clearly label the result as an ungraded fallback:
curl "https://financialmodelingprep.com/stable/earnings-calendar?from=YYYY-MM-DD&to=YYYY-MM-DD&apikey=$FMP_API_KEY"Then optionally enrich returned US tickers through the analyzer's stable-first FMP client or per-symbol /stable/quote?symbol=<ticker> calls to rank by same-day changesPercentage, market cap, and liquidity. Use legacy /api/v3 quote calls only as a legacy-key fallback after stable has failed. Present these as preliminary / ungraded reactions because the 5-factor scorer did not run; do not assign A/B/C/D grades from the fallback alone.
No-candidate output pitfall: The analyzer may print Candidates after filtering: 0 / No candidates found matching criteria. and exit successfully without writing an earnings_trade_analyzer_*.json file. In that case, do not try to run PEAD Mode B from a nonexistent candidate file. Say explicitly that no scored analyzer JSON was produced, run the endpoint/quote enrichment fallback above if the routine needs an earnings section, and label any names as manual-review only.
Step 2: Review Results
1. Read the generated JSON and Markdown reports 2. Load references/scoring_methodology.md for scoring interpretation context 3. Focus on Grade A and B stocks for actionable setups
Step 3: Present Analysis
For each top candidate, present:
- Composite score and letter grade (A/B/C/D)
- Earnings gap size and direction
- Pre-earnings 20-day trend
- Volume ratio (20-day vs 60-day average)
- Position relative to 200-day and 50-day moving averages
- Weakest and strongest scoring components
Step 4: Provide Actionable Guidance
Based on grades:
- Grade A (85+): Strong earnings reaction with institutional accumulation - consider entry
- Grade B (70-84): Good earnings reaction worth monitoring - wait for pullback or confirmation
- Grade C (55-69): Mixed signals - use caution, additional analysis needed
- Grade D (<55): Weak setup - avoid or wait for better conditions
Output
earnings_trade_analyzer_YYYY-MM-DD_HHMMSS.json- Structured results with schema_version "1.0"earnings_trade_analyzer_YYYY-MM-DD_HHMMSS.md- Human-readable report with tables
Resources
references/scoring_methodology.md- 5-factor scoring system, grade thresholds, and entry quality filter rules
Earnings Trade Analyzer - Scoring Methodology
Overview
The Earnings Trade Analyzer uses a 5-factor weighted scoring system to evaluate post-earnings stock setups. Each stock receives a composite score (0-100) and a letter grade (A/B/C/D).
5-Factor Scoring System
Factor 1: Gap Size (25% Weight)
Measures the magnitude of the earnings gap, regardless of direction. Larger gaps indicate stronger institutional conviction.
| Absolute Gap | Score |
|---|---|
| >= 10% | 100 |
| >= 7% | 85 |
| >= 5% | 70 |
| >= 3% | 55 |
| >= 1% | 35 |
| < 1% | 15 |
Timing Logic:
- BMO (Before Market Open): gap = open[earnings_date] / close[previous_day] - 1
- AMC (After Market Close): gap = open[next_day] / close[earnings_date] - 1
- Unknown: Uses AMC logic as default
Factor 2: Pre-Earnings Trend (30% Weight)
Measures the 20-day price return leading into earnings. Stocks trending up before a positive earnings gap show stronger momentum characteristics.
| 20-Day Return | Score |
|---|---|
| >= 15% | 100 |
| >= 10% | 85 |
| >= 5% | 70 |
| >= 0% | 50 |
| >= -5% | 30 |
| < -5% | 15 |
Factor 3: Volume Trend (20% Weight)
Ratio of 20-day average volume to 60-day average volume around the earnings date. Higher ratios indicate increased institutional interest and accumulation.
| Volume Ratio (20d/60d) | Score |
|---|---|
| >= 2.0x | 100 |
| >= 1.5x | 80 |
| >= 1.2x | 60 |
| >= 1.0x | 40 |
| < 1.0x | 20 |
Factor 4: MA200 Position (15% Weight)
Current price position relative to the 200-day Simple Moving Average. Stocks above their 200-day SMA are in long-term uptrends.
| Distance from MA200 | Score |
|---|---|
| >= 20% | 100 |
| >= 10% | 85 |
| >= 5% | 70 |
| >= 0% | 55 |
| >= -5% | 35 |
| < -5% | 15 |
Factor 5: MA50 Position (10% Weight)
Current price position relative to the 50-day Simple Moving Average. Stocks above their 50-day SMA show medium-term strength.
| Distance from MA50 | Score |
|---|---|
| >= 10% | 100 |
| >= 5% | 80 |
| >= 0% | 60 |
| >= -5% | 35 |
| < -5% | 15 |
Grade Thresholds
| Grade | Score Range | Description |
|---|---|---|
| A | 85-100 | Strong earnings reaction with institutional accumulation |
| B | 70-84 | Good earnings reaction worth monitoring |
| C | 55-69 | Mixed signals, use caution |
| D | 0-54 | Weak setup, avoid |
Entry Quality Filter
When --apply-entry-filter is enabled, the following stocks are excluded:
- Stocks with current price < $10 (penny stock territory, lower institutional interest)
Historical Win Rate Context
The scoring system is designed to identify stocks with the highest probability of continued post-earnings momentum. Key observations:
- Grade A stocks (85+) historically show the strongest post-earnings drift (PEAD)
- Pre-earnings trend receives the highest weight (30%) because momentum heading into earnings is the strongest predictor of post-earnings continuation
- Volume confirmation (20%) validates institutional participation in the earnings move
- Moving average positions (25% combined) confirm the stock is in a favorable trend structure
Composite Score Formula
composite_score = (gap_score * 0.25) + (trend_score * 0.30) + (volume_score * 0.20)
+ (ma200_score * 0.15) + (ma50_score * 0.10)The weights sum to exactly 1.0 (100%).
"""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
#!/usr/bin/env python3
"""
Earnings Trade Analyzer - Main Orchestrator
Analyzes recent post-earnings stocks using a 5-factor scoring system:
1. Gap Size (25%)
2. Pre-Earnings Trend (30%)
3. Volume Trend (20%)
4. MA200 Position (15%)
5. MA50 Position (10%)
Scores each stock 0-100 and assigns A/B/C/D grades.
4-Phase Pipeline:
Phase 1: Fetch earnings calendar, profiles, filter by market cap + US exchange
Phase 1.5: Budget check - estimate remaining API calls, trim if needed
Phase 2: Fetch historical daily prices (250 days) for each candidate
Phase 3: Score all 5 factors, composite score, grade, optional entry filter
Phase 4: Generate JSON + Markdown reports
Usage:
python3 analyze_earnings_trades.py --output-dir reports/
python3 analyze_earnings_trades.py --lookback-days 5 --min-market-cap 1000000000
python3 analyze_earnings_trades.py --apply-entry-filter --top 30
"""
import argparse
import os
import sys
from datetime import datetime, timedelta
# Add scripts directory to path for imports
sys.path.insert(0, os.path.dirname(__file__))
from calculators.gap_size_calculator import calculate_gap
from calculators.ma50_calculator import calculate_ma50_position
from calculators.ma200_calculator import calculate_ma200_position
from calculators.pre_earnings_trend_calculator import calculate_pre_earnings_trend
from calculators.volume_trend_calculator import calculate_volume_trend
from fmp_client import ApiCallBudgetExceeded, FMPClient
from report_generator import generate_json_report, generate_markdown_report
from scorer import calculate_composite_score
def normalize_timing(time_value):
"""Normalize FMP time field to bmo/amc/unknown."""
if not time_value:
return "unknown"
t = time_value.lower().strip()
if t in ("bmo", "pre-market", "before market open"):
return "bmo"
elif t in ("amc", "after-market", "after market close"):
return "amc"
else:
return "unknown"
def analyze_stock(daily_prices, earnings_date, timing):
"""Score a single stock across all 5 factors.
Args:
daily_prices: List of price dicts (most-recent-first)
earnings_date: YYYY-MM-DD string
timing: 'bmo', 'amc', or 'unknown'
Returns:
dict with component results and composite score
"""
gap_result = calculate_gap(daily_prices, earnings_date, timing)
trend_result = calculate_pre_earnings_trend(daily_prices, earnings_date)
volume_result = calculate_volume_trend(daily_prices, earnings_date)
ma200_result = calculate_ma200_position(daily_prices)
ma50_result = calculate_ma50_position(daily_prices)
composite = calculate_composite_score(
gap_score=gap_result["score"],
trend_score=trend_result["score"],
volume_score=volume_result["score"],
ma200_score=ma200_result["score"],
ma50_score=ma50_result["score"],
)
return {
"gap": gap_result,
"pre_earnings_trend": trend_result,
"volume_trend": volume_result,
"ma200_position": ma200_result,
"ma50_position": ma50_result,
"composite": composite,
}
def apply_entry_filter(results):
"""Apply entry quality filter to exclude poor setups.
Based on 517-trade backtest analysis (entry_filter.py):
1. Exclude price < $30: Win Rate 40.6% for $10-$30 range (vs 54.5% baseline)
2. Exclude gap >= 10% AND score >= 85: Win Rate 33.3% (paradox pattern)
"""
filtered = []
for r in results:
price = r.get("current_price", 0)
gap_pct = abs(r.get("gap_pct", 0))
score = r.get("composite_score", 0)
# Rule 1: Low price band exclusion (< $30)
if price < 30:
continue
# Rule 2: High gap + high score paradox exclusion
if gap_pct >= 10 and score >= 85:
continue
filtered.append(r)
return filtered
def main():
parser = argparse.ArgumentParser(
description="Earnings Trade Analyzer - 5-Factor Post-Earnings Scoring"
)
parser.add_argument(
"--api-key", type=str, default=None, help="FMP API key (or set FMP_API_KEY env var)"
)
parser.add_argument(
"--lookback-days", type=int, default=2, help="Days back for earnings (default: 2)"
)
parser.add_argument(
"--min-market-cap",
type=float,
default=500_000_000,
help="Minimum market cap in dollars (default: 500000000)",
)
parser.add_argument("--min-gap", type=float, default=0, help="Minimum gap %% (default: 0)")
parser.add_argument(
"--max-api-calls",
type=int,
default=200,
help="API call budget (default: 200)",
)
parser.add_argument(
"--apply-entry-filter",
action="store_true",
help="Apply entry quality filter (exclude price < $30, exclude gap>=10%% AND score>=85)",
)
parser.add_argument("--top", type=int, default=20, help="Top results to include (default: 20)")
parser.add_argument(
"--output-dir",
type=str,
default="reports/",
help="Output directory (default: reports/)",
)
args = parser.parse_args()
# Initialize FMP client
try:
client = FMPClient(api_key=args.api_key, max_api_calls=args.max_api_calls)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
print("=" * 60, file=sys.stderr)
print("Earnings Trade Analyzer - 5-Factor Scoring", file=sys.stderr)
print("=" * 60, file=sys.stderr)
# Phase 1: Fetch earnings calendar and profiles
print("\n--- Phase 1: Fetch Earnings Calendar ---", file=sys.stderr)
today = datetime.now()
from_date = (today - timedelta(days=args.lookback_days)).strftime("%Y-%m-%d")
to_date = today.strftime("%Y-%m-%d")
print(f"Date range: {from_date} to {to_date}", file=sys.stderr)
try:
earnings = client.get_earnings_calendar(from_date, to_date)
except ApiCallBudgetExceeded as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if not earnings:
print("ERROR: No earnings data returned from API.", file=sys.stderr)
sys.exit(1)
print(f"Raw earnings announcements: {len(earnings)}", file=sys.stderr)
# Get unique symbols
symbols = list(set(e.get("symbol") for e in earnings if e.get("symbol")))
print(f"Unique symbols: {len(symbols)}", file=sys.stderr)
# Fetch profiles in batch
print("Fetching company profiles...", file=sys.stderr)
try:
profiles = client.get_company_profiles(symbols)
except ApiCallBudgetExceeded as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
print(f"Profiles retrieved: {len(profiles)}", file=sys.stderr)
# Filter by market cap and US exchange
candidates = []
for earning in earnings:
symbol = earning.get("symbol")
if not symbol or symbol not in profiles:
continue
profile = profiles[symbol]
market_cap = profile.get("mktCap", 0)
exchange = profile.get("exchangeShortName", "")
if market_cap < args.min_market_cap:
continue
if exchange not in FMPClient.US_EXCHANGES:
continue
timing = normalize_timing(earning.get("time"))
candidates.append(
{
"symbol": symbol,
"company_name": profile.get("companyName", symbol),
"earnings_date": earning.get("date"),
"earnings_timing": timing,
"market_cap": market_cap,
"sector": profile.get("sector", "N/A"),
"industry": profile.get("industry", "N/A"),
"price": profile.get("price", 0),
}
)
# Deduplicate by symbol (keep first occurrence)
seen = set()
unique_candidates = []
for c in candidates:
if c["symbol"] not in seen:
seen.add(c["symbol"])
unique_candidates.append(c)
candidates = unique_candidates
print(f"Candidates after filtering: {len(candidates)}", file=sys.stderr)
if not candidates:
print("No candidates found matching criteria.", file=sys.stderr)
sys.exit(0)
# Phase 1.5: Budget check
print("\n--- Phase 1.5: Budget Check ---", file=sys.stderr)
remaining_calls = args.max_api_calls - client.api_calls_made
estimated_calls = len(candidates) # 1 historical price call per candidate
if estimated_calls > remaining_calls:
print(
f"WARNING: Estimated {estimated_calls} calls needed, "
f"but only {remaining_calls} remaining in budget.",
file=sys.stderr,
)
# Trim candidates by market cap (descending) to fit budget
candidates.sort(key=lambda x: x.get("market_cap", 0), reverse=True)
candidates = candidates[:remaining_calls]
print(f"Trimmed to {len(candidates)} candidates (by market cap).", file=sys.stderr)
else:
print(
f"Budget OK: {estimated_calls} calls needed, {remaining_calls} remaining.",
file=sys.stderr,
)
# Phase 2: Fetch historical prices
print("\n--- Phase 2: Fetch Historical Prices ---", file=sys.stderr)
results = []
for i, candidate in enumerate(candidates):
symbol = candidate["symbol"]
print(
f" [{i + 1}/{len(candidates)}] Fetching {symbol}...",
file=sys.stderr,
end="",
)
try:
daily_prices = client.get_historical_prices(symbol, days=250)
except ApiCallBudgetExceeded:
print(
f"\nWARNING: API budget exceeded at {symbol}. "
f"Proceeding with {len(results)} results.",
file=sys.stderr,
)
break
if not daily_prices or len(daily_prices) < 50:
print(
f" SKIP (insufficient data: {len(daily_prices) if daily_prices else 0} days)",
file=sys.stderr,
)
continue
# Phase 3: Score all 5 factors
analysis = analyze_stock(
daily_prices,
candidate["earnings_date"],
candidate["earnings_timing"],
)
composite = analysis["composite"]
gap_pct = analysis["gap"]["gap_pct"]
# Apply min gap filter
if abs(gap_pct) < args.min_gap:
print(f" SKIP (gap {gap_pct:.1f}% < min {args.min_gap}%)", file=sys.stderr)
continue
current_price = daily_prices[0]["close"] if daily_prices else candidate["price"]
result = {
"symbol": symbol,
"company_name": candidate["company_name"],
"earnings_date": candidate["earnings_date"],
"earnings_timing": candidate["earnings_timing"],
"gap_pct": gap_pct,
"composite_score": composite["composite_score"],
"grade": composite["grade"],
"grade_description": composite["grade_description"],
"guidance": composite["guidance"],
"weakest_component": composite["weakest_component"],
"strongest_component": composite["strongest_component"],
"component_breakdown": composite["component_breakdown"],
"current_price": round(current_price, 2),
"market_cap": candidate["market_cap"],
"sector": candidate["sector"],
"industry": candidate["industry"],
"components": {
"gap_size": analysis["gap"],
"pre_earnings_trend": analysis["pre_earnings_trend"],
"volume_trend": analysis["volume_trend"],
"ma200_position": analysis["ma200_position"],
"ma50_position": analysis["ma50_position"],
},
}
results.append(result)
print(
f" Grade {composite['grade']} (score: {composite['composite_score']:.1f})",
file=sys.stderr,
)
print(f"\nScored {len(results)} stocks.", file=sys.stderr)
# Apply entry quality filter if requested
all_results = results[:]
if args.apply_entry_filter:
results = apply_entry_filter(results)
print(f"After entry filter: {len(results)} stocks.", file=sys.stderr)
all_results = results[:]
# Sort by composite score descending
results.sort(key=lambda x: x.get("composite_score", 0), reverse=True)
all_results.sort(key=lambda x: x.get("composite_score", 0), reverse=True)
# Take top N
top_results = results[: args.top]
# Phase 4: Generate reports
print("\n--- Phase 4: Generate Reports ---", file=sys.stderr)
api_stats = client.get_api_stats()
metadata = {
"generated_at": datetime.now().isoformat(),
"generator": "earnings-trade-analyzer",
"generator_version": "1.0.0",
"lookback_days": args.lookback_days,
"total_screened": len(all_results),
"min_market_cap": args.min_market_cap,
"min_gap": args.min_gap,
"entry_filter_applied": args.apply_entry_filter,
"api_stats": api_stats,
}
os.makedirs(args.output_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_path = os.path.join(args.output_dir, f"earnings_trade_analyzer_{timestamp}.json")
md_path = os.path.join(args.output_dir, f"earnings_trade_analyzer_{timestamp}.md")
generate_json_report(top_results, metadata, json_path, all_results=all_results)
generate_markdown_report(top_results, metadata, md_path, all_results=all_results)
print(f"JSON report: {json_path}", file=sys.stderr)
print(f"Markdown report: {md_path}", file=sys.stderr)
print(
f"API calls used: {api_stats['api_calls_made']}/{api_stats['max_api_calls']}",
file=sys.stderr,
)
print("Done.", file=sys.stderr)
if __name__ == "__main__":
main()
# Earnings Trade Analyzer Calculators
#!/usr/bin/env python3
"""
Gap Size Calculator for Earnings Trade Analyzer
Calculates earnings gap based on BMO/AMC timing and scores the gap magnitude.
BMO (Before Market Open): gap = (open[earnings_date] / close[prev_day]) - 1
AMC (After Market Close) / unknown: gap = (open[next_day] / close[earnings_date]) - 1
Scoring (25% weight):
|gap| >= 10%: 100
|gap| >= 7%: 85
|gap| >= 5%: 70
|gap| >= 3%: 55
|gap| >= 1%: 35
|gap| < 1%: 15
"""
def _find_index_by_date(daily_prices: list[dict], target_date: str) -> int:
"""Find the index of target_date in daily_prices (most-recent-first).
Returns:
Index if found, -1 otherwise.
"""
for i, bar in enumerate(daily_prices):
if bar.get("date") == target_date:
return i
return -1
def _score_gap(abs_gap_pct: float) -> float:
"""Score the absolute gap percentage.
Args:
abs_gap_pct: Absolute gap percentage (e.g. 6.3 for 6.3%)
Returns:
Score from 0 to 100.
"""
if abs_gap_pct >= 10.0:
return 100.0
elif abs_gap_pct >= 7.0:
return 85.0
elif abs_gap_pct >= 5.0:
return 70.0
elif abs_gap_pct >= 3.0:
return 55.0
elif abs_gap_pct >= 1.0:
return 35.0
else:
return 15.0
def calculate_gap(daily_prices: list[dict], earnings_date: str, timing: str) -> dict:
"""
Calculate earnings gap based on BMO/AMC timing.
Args:
daily_prices: list of dicts with 'date', 'open', 'close' (most-recent-first)
earnings_date: YYYY-MM-DD string
timing: 'bmo', 'amc', or 'unknown'
BMO: gap = (open[earnings_date] / close[prev_day]) - 1
AMC/unknown: gap = (open[next_day] / close[earnings_date]) - 1
"prev_day" and "next_day" are resolved by index in daily_prices
(business days only since we use actual trading data).
Returns:
dict with:
- gap_pct: float (percentage, e.g. 6.3 for 6.3%)
- gap_type: 'up' or 'down'
- base_price: float (denominator in gap calc)
- gap_price: float (numerator in gap calc)
- timing_used: str
- score: float (0-100)
- warning: str (optional, if data issue)
"""
earnings_idx = _find_index_by_date(daily_prices, earnings_date)
if earnings_idx == -1:
return {
"gap_pct": 0.0,
"gap_type": "up",
"base_price": 0.0,
"gap_price": 0.0,
"timing_used": timing,
"score": 0.0,
"warning": f"Earnings date {earnings_date} not found in price data",
}
timing_lower = timing.lower() if timing else "unknown"
if timing_lower == "bmo":
# BMO: gap = open[earnings_date] / close[prev_day] - 1
# In most-recent-first order, prev_day is at index earnings_idx + 1
prev_idx = earnings_idx + 1
if prev_idx >= len(daily_prices):
return {
"gap_pct": 0.0,
"gap_type": "up",
"base_price": 0.0,
"gap_price": 0.0,
"timing_used": timing_lower,
"score": 0.0,
"warning": "No previous trading day available for BMO gap calculation",
}
base_price = daily_prices[prev_idx]["close"]
gap_price = daily_prices[earnings_idx]["open"]
else:
# AMC or unknown: gap = open[next_day] / close[earnings_date] - 1
# In most-recent-first order, next_day is at index earnings_idx - 1
next_idx = earnings_idx - 1
if next_idx < 0:
return {
"gap_pct": 0.0,
"gap_type": "up",
"base_price": 0.0,
"gap_price": 0.0,
"timing_used": timing_lower,
"score": 0.0,
"warning": "No next trading day available for AMC gap calculation",
}
base_price = daily_prices[earnings_idx]["close"]
gap_price = daily_prices[next_idx]["open"]
if base_price == 0:
return {
"gap_pct": 0.0,
"gap_type": "up",
"base_price": base_price,
"gap_price": gap_price,
"timing_used": timing_lower,
"score": 0.0,
"warning": "Base price is zero, cannot calculate gap",
}
gap_pct = ((gap_price / base_price) - 1.0) * 100.0
abs_gap = abs(gap_pct)
gap_type = "up" if gap_pct >= 0 else "down"
score = _score_gap(abs_gap)
result = {
"gap_pct": round(gap_pct, 2),
"gap_type": gap_type,
"base_price": round(base_price, 2),
"gap_price": round(gap_price, 2),
"timing_used": timing_lower,
"score": score,
}
return result
#!/usr/bin/env python3
"""
MA200 Position Calculator for Earnings Trade Analyzer
Calculates the current price position relative to the 200-day Simple Moving Average.
SMA200 is computed from daily close prices directly.
Scoring (15% weight):
distance >= 20%: 100
distance >= 10%: 85
distance >= 5%: 70
distance >= 0%: 55
distance >= -5%: 35
distance < -5%: 15
"""
def _score_ma200_distance(distance_pct: float) -> float:
"""Score the distance from MA200.
Args:
distance_pct: Percentage distance from MA200 (positive = above)
Returns:
Score from 0 to 100.
"""
if distance_pct >= 20.0:
return 100.0
elif distance_pct >= 10.0:
return 85.0
elif distance_pct >= 5.0:
return 70.0
elif distance_pct >= 0.0:
return 55.0
elif distance_pct >= -5.0:
return 35.0
else:
return 15.0
def calculate_ma200_position(daily_prices: list[dict]) -> dict:
"""
Calculate current price position relative to 200-day SMA.
Compute SMA200 from daily_prices close values.
Args:
daily_prices: list of dicts with 'close' (most-recent-first)
Returns:
dict with:
- ma200: float
- distance_pct: float (positive = above MA)
- above_ma200: bool
- score: float (0-100)
- warning: str (optional)
"""
if len(daily_prices) < 200:
return {
"ma200": 0.0,
"distance_pct": 0.0,
"above_ma200": False,
"score": 0.0,
"warning": f"Insufficient data for MA200: {len(daily_prices)} days available, 200 required",
}
# Calculate SMA200 from the first 200 close prices (most-recent-first)
closes_200 = [daily_prices[i]["close"] for i in range(200)]
ma200 = sum(closes_200) / 200.0
current_price = daily_prices[0]["close"]
if ma200 == 0:
return {
"ma200": 0.0,
"distance_pct": 0.0,
"above_ma200": False,
"score": 0.0,
"warning": "MA200 is zero",
}
distance_pct = ((current_price / ma200) - 1.0) * 100.0
above_ma200 = distance_pct >= 0
score = _score_ma200_distance(distance_pct)
result = {
"ma200": round(ma200, 2),
"distance_pct": round(distance_pct, 2),
"above_ma200": above_ma200,
"score": score,
}
return result
#!/usr/bin/env python3
"""
MA50 Position Calculator for Earnings Trade Analyzer
Calculates the current price position relative to the 50-day Simple Moving Average.
SMA50 is computed from daily close prices directly.
Scoring (10% weight):
distance >= 10%: 100
distance >= 5%: 80
distance >= 0%: 60
distance >= -5%: 35
distance < -5%: 15
"""
def _score_ma50_distance(distance_pct: float) -> float:
"""Score the distance from MA50.
Args:
distance_pct: Percentage distance from MA50 (positive = above)
Returns:
Score from 0 to 100.
"""
if distance_pct >= 10.0:
return 100.0
elif distance_pct >= 5.0:
return 80.0
elif distance_pct >= 0.0:
return 60.0
elif distance_pct >= -5.0:
return 35.0
else:
return 15.0
def calculate_ma50_position(daily_prices: list[dict]) -> dict:
"""
Calculate current price position relative to 50-day SMA.
Args:
daily_prices: list of dicts with 'close' (most-recent-first)
Returns:
dict with:
- ma50: float
- distance_pct: float (positive = above MA)
- above_ma50: bool
- score: float (0-100)
- warning: str (optional)
"""
if len(daily_prices) < 50:
return {
"ma50": 0.0,
"distance_pct": 0.0,
"above_ma50": False,
"score": 0.0,
"warning": f"Insufficient data for MA50: {len(daily_prices)} days available, 50 required",
}
# Calculate SMA50 from the first 50 close prices (most-recent-first)
closes_50 = [daily_prices[i]["close"] for i in range(50)]
ma50 = sum(closes_50) / 50.0
current_price = daily_prices[0]["close"]
if ma50 == 0:
return {
"ma50": 0.0,
"distance_pct": 0.0,
"above_ma50": False,
"score": 0.0,
"warning": "MA50 is zero",
}
distance_pct = ((current_price / ma50) - 1.0) * 100.0
above_ma50 = distance_pct >= 0
score = _score_ma50_distance(distance_pct)
result = {
"ma50": round(ma50, 2),
"distance_pct": round(distance_pct, 2),
"above_ma50": above_ma50,
"score": score,
}
return result
#!/usr/bin/env python3
"""
Pre-Earnings Trend Calculator for Earnings Trade Analyzer
Calculates the 20-day price return before the earnings date to assess
the stock's momentum heading into the earnings report.
Scoring (30% weight):
return >= 15%: 100
return >= 10%: 85
return >= 5%: 70
return >= 0%: 50
return >= -5%: 30
return < -5%: 15
"""
def _find_index_by_date(daily_prices: list[dict], target_date: str) -> int:
"""Find the index of target_date in daily_prices (most-recent-first).
Returns:
Index if found, -1 otherwise.
"""
for i, bar in enumerate(daily_prices):
if bar.get("date") == target_date:
return i
return -1
def _score_trend(return_pct: float) -> float:
"""Score the 20-day pre-earnings return.
Args:
return_pct: 20-day return percentage (e.g. 8.5 for 8.5%)
Returns:
Score from 0 to 100.
"""
if return_pct >= 15.0:
return 100.0
elif return_pct >= 10.0:
return 85.0
elif return_pct >= 5.0:
return 70.0
elif return_pct >= 0.0:
return 50.0
elif return_pct >= -5.0:
return 30.0
else:
return 15.0
def calculate_pre_earnings_trend(daily_prices: list[dict], earnings_date: str) -> dict:
"""
Calculate 20-day return before earnings date.
Args:
daily_prices: list of dicts with 'date', 'close' (most-recent-first)
earnings_date: YYYY-MM-DD string
Returns:
dict with:
- return_20d_pct: float
- trend_direction: 'up' or 'down'
- score: float (0-100)
- warning: str (optional)
"""
earnings_idx = _find_index_by_date(daily_prices, earnings_date)
if earnings_idx == -1:
return {
"return_20d_pct": 0.0,
"trend_direction": "up",
"score": 0.0,
"warning": f"Earnings date {earnings_date} not found in price data",
}
# 20 trading days before earnings date
# In most-recent-first, 20 days before is at earnings_idx + 20
lookback_idx = earnings_idx + 20
if lookback_idx >= len(daily_prices):
return {
"return_20d_pct": 0.0,
"trend_direction": "up",
"score": 0.0,
"warning": "Insufficient data for 20-day pre-earnings trend calculation",
}
close_at_earnings = daily_prices[earnings_idx]["close"]
close_20d_before = daily_prices[lookback_idx]["close"]
if close_20d_before == 0:
return {
"return_20d_pct": 0.0,
"trend_direction": "up",
"score": 0.0,
"warning": "Price 20 days before earnings is zero",
}
return_20d_pct = ((close_at_earnings / close_20d_before) - 1.0) * 100.0
trend_direction = "up" if return_20d_pct >= 0 else "down"
score = _score_trend(return_20d_pct)
result = {
"return_20d_pct": round(return_20d_pct, 2),
"trend_direction": trend_direction,
"score": score,
}
return result
#!/usr/bin/env python3
"""
Volume Trend Calculator for Earnings Trade Analyzer
Calculates the volume ratio around the earnings date:
ratio = 20-day average volume / 60-day average volume
A ratio > 1.0 indicates increased institutional interest around earnings.
Scoring (20% weight):
ratio >= 2.0: 100
ratio >= 1.5: 80
ratio >= 1.2: 60
ratio >= 1.0: 40
ratio < 1.0: 20
"""
def _find_index_by_date(daily_prices: list[dict], target_date: str) -> int:
"""Find the index of target_date in daily_prices (most-recent-first).
Returns:
Index if found, -1 otherwise.
"""
for i, bar in enumerate(daily_prices):
if bar.get("date") == target_date:
return i
return -1
def _score_volume_ratio(ratio: float) -> float:
"""Score the volume ratio.
Args:
ratio: 20-day avg volume / 60-day avg volume
Returns:
Score from 0 to 100.
"""
if ratio >= 2.0:
return 100.0
elif ratio >= 1.5:
return 80.0
elif ratio >= 1.2:
return 60.0
elif ratio >= 1.0:
return 40.0
else:
return 20.0
def calculate_volume_trend(daily_prices: list[dict], earnings_date: str) -> dict:
"""
Calculate volume ratio: 20-day avg / 60-day avg around earnings date.
Args:
daily_prices: list of dicts with 'date', 'volume' (most-recent-first)
earnings_date: YYYY-MM-DD string
Returns:
dict with:
- vol_ratio_20_60: float
- recent_avg_volume: int
- longer_avg_volume: int
- score: float (0-100)
- warning: str (optional)
"""
earnings_idx = _find_index_by_date(daily_prices, earnings_date)
if earnings_idx == -1:
return {
"vol_ratio_20_60": 0.0,
"recent_avg_volume": 0,
"longer_avg_volume": 0,
"score": 0.0,
"warning": f"Earnings date {earnings_date} not found in price data",
}
# Calculate 20-day average volume starting from earnings date
# In most-recent-first order, the 20 days around/after earnings
# are at indices max(0, earnings_idx - 10) to earnings_idx + 10
# But simpler: use earnings_idx as center, take 20 days from earnings_idx forward
start_20 = earnings_idx
end_20 = min(earnings_idx + 20, len(daily_prices))
if end_20 - start_20 < 5:
return {
"vol_ratio_20_60": 0.0,
"recent_avg_volume": 0,
"longer_avg_volume": 0,
"score": 0.0,
"warning": "Insufficient data for 20-day volume calculation",
}
volumes_20 = [daily_prices[i]["volume"] for i in range(start_20, end_20)]
recent_avg = sum(volumes_20) / len(volumes_20)
# Calculate 60-day average volume
start_60 = earnings_idx
end_60 = min(earnings_idx + 60, len(daily_prices))
if end_60 - start_60 < 20:
return {
"vol_ratio_20_60": 0.0,
"recent_avg_volume": int(recent_avg),
"longer_avg_volume": 0,
"score": 0.0,
"warning": "Insufficient data for 60-day volume calculation",
}
volumes_60 = [daily_prices[i]["volume"] for i in range(start_60, end_60)]
longer_avg = sum(volumes_60) / len(volumes_60)
if longer_avg == 0:
return {
"vol_ratio_20_60": 0.0,
"recent_avg_volume": int(recent_avg),
"longer_avg_volume": 0,
"score": 0.0,
"warning": "60-day average volume is zero",
}
vol_ratio = recent_avg / longer_avg
score = _score_volume_ratio(vol_ratio)
result = {
"vol_ratio_20_60": round(vol_ratio, 2),
"recent_avg_volume": int(recent_avg),
"longer_avg_volume": int(longer_avg),
"score": score,
}
return result
#!/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 Earnings Trade Analyzer
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
US_EXCHANGES = ["NYSE", "NASDAQ", "AMEX", "NYSEArca", "BATS", "NMS", "NGM", "NCM"]
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 = 250) -> Optional[list[dict]]:
"""Fetch historical daily OHLCV data for a symbol.
Args:
symbol: Ticker symbol
days: Number of trading days to fetch (default: 250)
Returns:
List of price dicts (most-recent-first) or None on failure.
"""
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 and "historical" in data:
result = data["historical"]
self.cache[cache_key] = result
return result
return None
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
"""
Report Generator for Earnings Trade Analyzer
Generates JSON and Markdown reports from scored earnings trade results.
JSON output uses schema_version "1.0" for pead-screener compatibility.
"""
import json
import os
from datetime import datetime
def _format_market_cap(market_cap):
"""Format market cap in human-readable format."""
if not market_cap or market_cap == 0:
return "N/A"
if market_cap >= 1e12:
return f"${market_cap / 1e12:.1f}T"
elif market_cap >= 1e9:
return f"${market_cap / 1e9:.1f}B"
elif market_cap >= 1e6:
return f"${market_cap / 1e6:.0f}M"
else:
return f"${market_cap:,.0f}"
def _generate_summary(results: list[dict]) -> dict:
"""Generate summary statistics from results.
Args:
results: List of scored stock result dicts
Returns:
Summary dict with counts by grade.
"""
summary = {
"total": len(results),
"grade_a": 0,
"grade_b": 0,
"grade_c": 0,
"grade_d": 0,
}
for r in results:
grade = r.get("grade", "D")
if grade == "A":
summary["grade_a"] += 1
elif grade == "B":
summary["grade_b"] += 1
elif grade == "C":
summary["grade_c"] += 1
else:
summary["grade_d"] += 1
return summary
def generate_json_report(
results: list[dict],
metadata: dict,
output_path: str,
all_results: list[dict] = None,
) -> str:
"""Generate JSON report with schema_version "1.0".
Args:
results: List of top scored results to include
metadata: Metadata dict (generated_at, lookback_days, etc.)
output_path: Path to write JSON file
all_results: All results for summary counts (defaults to results)
Returns:
Path to the generated JSON file.
"""
if all_results is None:
all_results = results
summary = _generate_summary(all_results)
# Build sector distribution
sector_distribution = {}
for r in all_results:
sector = r.get("sector", "Unknown")
sector_distribution[sector] = sector_distribution.get(sector, 0) + 1
report = {
"schema_version": "1.0",
"metadata": metadata,
"results": [
{
"symbol": r.get("symbol"),
"company_name": r.get("company_name"),
"earnings_date": r.get("earnings_date"),
"earnings_timing": r.get("earnings_timing"),
"gap_pct": r.get("gap_pct"),
"composite_score": r.get("composite_score"),
"grade": r.get("grade"),
"current_price": r.get("current_price"),
"market_cap": r.get("market_cap"),
"sector": r.get("sector"),
"components": r.get("components", {}),
}
for r in results
],
"summary": summary,
"sector_distribution": sector_distribution,
}
os.makedirs(
os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True
)
with open(output_path, "w") as f:
json.dump(report, f, indent=2)
return output_path
def generate_markdown_report(
results: list[dict],
metadata: dict,
output_path: str,
all_results: list[dict] = None,
) -> str:
"""Generate Markdown report with tables.
Args:
results: List of top scored results to include
metadata: Metadata dict
output_path: Path to write Markdown file
all_results: All results for summary counts (defaults to results)
Returns:
Path to the generated Markdown file.
"""
if all_results is None:
all_results = results
summary = _generate_summary(all_results)
now = metadata.get("generated_at", datetime.now().isoformat())
lookback_days = metadata.get("lookback_days", "N/A")
lines = []
lines.append("# Earnings Trade Analyzer Report")
lines.append("")
lines.append(f"**Generated:** {now}")
lines.append(f"**Lookback:** {lookback_days} days")
lines.append(f"**Total Screened:** {summary['total']}")
lines.append("")
# Show top count if limited
if len(results) < len(all_results):
lines.append(f"*Showing top {len(results)} of {len(all_results)} candidates*")
lines.append("")
# Summary
lines.append("## Summary")
lines.append("")
lines.append("| Grade | Count |")
lines.append("|-------|-------|")
lines.append(f"| A (85+) | {summary['grade_a']} |")
lines.append(f"| B (70-84) | {summary['grade_b']} |")
lines.append(f"| C (55-69) | {summary['grade_c']} |")
lines.append(f"| D (<55) | {summary['grade_d']} |")
lines.append(f"| **Total** | **{summary['total']}** |")
lines.append("")
# Results table
lines.append("## Top Results")
lines.append("")
lines.append(
"| Rank | Symbol | Grade | Score | Gap% | Trend% | Vol Ratio | MA200 | MA50 | Mkt Cap |"
)
lines.append(
"|------|--------|-------|-------|------|--------|-----------|-------|------|---------|"
)
for i, r in enumerate(results, 1):
symbol = r.get("symbol", "???")
grade = r.get("grade", "D")
score = r.get("composite_score", 0)
gap_pct = r.get("gap_pct", 0)
components = r.get("components", {})
trend_pct = components.get("pre_earnings_trend", {}).get("return_20d_pct", 0)
vol_ratio = components.get("volume_trend", {}).get("vol_ratio_20_60", 0)
ma200_dist = components.get("ma200_position", {}).get("distance_pct", 0)
ma50_dist = components.get("ma50_position", {}).get("distance_pct", 0)
market_cap = r.get("market_cap", 0)
lines.append(
f"| {i} | **{symbol}** | {grade} | {score:.1f} | "
f"{gap_pct:+.1f}% | {trend_pct:+.1f}% | {vol_ratio:.2f}x | "
f"{ma200_dist:+.1f}% | {ma50_dist:+.1f}% | {_format_market_cap(market_cap)} |"
)
lines.append("")
# Detailed cards for Grade A and B
grade_ab = [r for r in results if r.get("grade") in ("A", "B")]
if grade_ab:
lines.append("## Grade A & B Details")
lines.append("")
for r in grade_ab:
symbol = r.get("symbol", "???")
company = r.get("company_name", symbol)
grade = r.get("grade", "?")
score = r.get("composite_score", 0)
gap_pct = r.get("gap_pct", 0)
earnings_date = r.get("earnings_date", "N/A")
timing = r.get("earnings_timing", "unknown")
sector = r.get("sector", "N/A")
guidance = r.get("guidance", "")
weakest = r.get("weakest_component", "N/A")
strongest = r.get("strongest_component", "N/A")
components = r.get("components", {})
lines.append(f"### {symbol} - {company} (Grade {grade}, Score {score:.1f})")
lines.append("")
lines.append(f"- **Earnings Date:** {earnings_date} ({timing.upper()})")
lines.append(f"- **Gap:** {gap_pct:+.1f}%")
lines.append(f"- **Sector:** {sector}")
lines.append(f"- **Strongest Factor:** {strongest}")
lines.append(f"- **Weakest Factor:** {weakest}")
lines.append(f"- **Guidance:** {guidance}")
lines.append("")
# Component scores
lines.append("| Component | Score | Weight | Weighted |")
lines.append("|-----------|-------|--------|----------|")
breakdown = r.get("component_breakdown", {})
for comp_name, comp_data in breakdown.items():
lines.append(
f"| {comp_name} | {comp_data['score']:.0f} | "
f"{comp_data['weight']:.0%} | {comp_data['weighted_score']:.1f} |"
)
lines.append("")
# Sector distribution
sector_distribution = {}
for r in all_results:
sector = r.get("sector", "Unknown")
sector_distribution[sector] = sector_distribution.get(sector, 0) + 1
if sector_distribution:
lines.append("## Sector Distribution")
lines.append("")
lines.append("| Sector | Count |")
lines.append("|--------|-------|")
for sector, count in sorted(sector_distribution.items(), key=lambda x: -x[1]):
lines.append(f"| {sector} | {count} |")
lines.append("")
# Methodology reference
lines.append("---")
lines.append("")
lines.append("*Scoring methodology reference: see `references/scoring_methodology.md`*")
lines.append("")
os.makedirs(
os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True
)
with open(output_path, "w") as f:
f.write("\n".join(lines))
return output_path
#!/usr/bin/env python3
"""
Composite Scorer for Earnings Trade Analyzer
Combines 5 factor scores with fixed weights into a composite score (0-100)
and assigns letter grades (A/B/C/D).
Component Weights:
gap_size: 25%
pre_earnings_trend: 30%
volume_trend: 20%
ma200_position: 15%
ma50_position: 10%
Grade Thresholds:
A: 85+ "Strong earnings reaction with institutional accumulation"
B: 70-84 "Good earnings reaction worth monitoring"
C: 55-69 "Mixed signals, use caution"
D: <55 "Weak setup, avoid"
"""
COMPONENT_WEIGHTS = {
"gap_size": 0.25,
"pre_earnings_trend": 0.30,
"volume_trend": 0.20,
"ma200_position": 0.15,
"ma50_position": 0.10,
}
GRADE_THRESHOLDS = [
(85, "A", "Strong earnings reaction with institutional accumulation"),
(70, "B", "Good earnings reaction worth monitoring"),
(55, "C", "Mixed signals, use caution"),
(0, "D", "Weak setup, avoid"),
]
GRADE_GUIDANCE = {
"A": "Consider entry on pullback to gap support or breakout continuation. High conviction setup.",
"B": "Monitor for follow-through buying. Wait for pullback to key support or volume confirmation.",
"C": "Additional analysis needed. Consider waiting for clearer price action or catalyst.",
"D": "Avoid trading. Weak setup with poor risk/reward profile.",
}
def calculate_composite_score(
gap_score: float,
trend_score: float,
volume_score: float,
ma200_score: float,
ma50_score: float,
) -> dict:
"""
Calculate weighted composite score and assign grade.
Args:
gap_score: Gap size score (0-100)
trend_score: Pre-earnings trend score (0-100)
volume_score: Volume trend score (0-100)
ma200_score: MA200 position score (0-100)
ma50_score: MA50 position score (0-100)
Returns:
dict with:
- composite_score: float (0-100)
- grade: str ('A', 'B', 'C', or 'D')
- grade_description: str
- guidance: str
- weakest_component: str
- weakest_score: float
- strongest_component: str
- strongest_score: float
- component_breakdown: dict
"""
components = {
"Gap Size": {"score": gap_score, "weight": COMPONENT_WEIGHTS["gap_size"]},
"Pre-Earnings Trend": {
"score": trend_score,
"weight": COMPONENT_WEIGHTS["pre_earnings_trend"],
},
"Volume Trend": {"score": volume_score, "weight": COMPONENT_WEIGHTS["volume_trend"]},
"MA200 Position": {"score": ma200_score, "weight": COMPONENT_WEIGHTS["ma200_position"]},
"MA50 Position": {"score": ma50_score, "weight": COMPONENT_WEIGHTS["ma50_position"]},
}
composite_score = sum(comp["score"] * comp["weight"] for comp in components.values())
composite_score = round(composite_score, 1)
# Determine grade
grade = "D"
grade_description = "Weak setup, avoid"
for threshold, g, desc in GRADE_THRESHOLDS:
if composite_score >= threshold:
grade = g
grade_description = desc
break
guidance = GRADE_GUIDANCE.get(grade, "")
# Find weakest and strongest components
weakest_name = min(components, key=lambda k: components[k]["score"])
strongest_name = max(components, key=lambda k: components[k]["score"])
component_breakdown = {
name: {
"score": comp["score"],
"weight": comp["weight"],
"weighted_score": round(comp["score"] * comp["weight"], 1),
}
for name, comp in components.items()
}
return {
"composite_score": composite_score,
"grade": grade,
"grade_description": grade_description,
"guidance": guidance,
"weakest_component": weakest_name,
"weakest_score": components[weakest_name]["score"],
"strongest_component": strongest_name,
"strongest_score": components[strongest_name]["score"],
"component_breakdown": component_breakdown,
}
"""Shared fixtures for Earnings Trade Analyzer 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__))
#!/usr/bin/env python3
"""
Tests for Earnings Trade Analyzer modules.
Covers normal cases and failure cases for all 5 calculators,
scorer, report generator, and FMP client edge cases.
"""
import json
import os
import tempfile
from unittest.mock import MagicMock, patch
from analyze_earnings_trades import apply_entry_filter
from calculators.gap_size_calculator import calculate_gap
from calculators.ma50_calculator import calculate_ma50_position
from calculators.ma200_calculator import calculate_ma200_position
from calculators.pre_earnings_trend_calculator import calculate_pre_earnings_trend
from calculators.volume_trend_calculator import calculate_volume_trend
from fmp_client import ApiCallBudgetExceeded, FMPClient
from report_generator import generate_json_report, generate_markdown_report
from scorer import COMPONENT_WEIGHTS, calculate_composite_score
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_prices(n, start=100.0, daily_change=0.0, volume=1000000, start_date="2025-01-01"):
"""Generate synthetic price data (most-recent-first).
Args:
n: Number of bars
start: Starting close price (for most recent bar)
daily_change: Per-bar drift factor
volume: Volume for all bars
start_date: Date string for the most recent bar
Returns:
List of price dicts, most-recent-first.
"""
prices = []
p = start
for i in range(n):
p_day = p * (1 + daily_change * i)
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": round(p_day * 0.999, 2),
"high": round(p_day * 1.01, 2),
"low": round(p_day * 0.99, 2),
"close": round(p_day, 2),
"volume": volume,
}
)
return prices
def _make_earnings_prices(
earnings_date="2025-01-05",
pre_close=100.0,
earnings_open=106.0,
earnings_close=107.0,
post_open=108.0,
num_days=250,
volume=1000000,
):
"""Generate price data with a specific earnings date and gap.
Places the earnings date at a known position with controlled prices
around it. Returns most-recent-first data.
"""
prices = []
# Build chronological then reverse
# Place earnings at day 30 (arbitrary, gives us 20+ days before for trend)
earnings_day_idx = 30
for i in range(num_days):
if i < earnings_day_idx - 1:
# Days before the day before earnings
base = pre_close * (1 - 0.001 * (earnings_day_idx - 1 - i))
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": round(base * 0.999, 2),
"high": round(base * 1.01, 2),
"low": round(base * 0.99, 2),
"close": round(base, 2),
"volume": volume,
}
)
elif i == earnings_day_idx - 1:
# Day before earnings
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": round(pre_close * 0.999, 2),
"high": round(pre_close * 1.01, 2),
"low": round(pre_close * 0.99, 2),
"close": round(pre_close, 2),
"volume": volume,
}
)
elif i == earnings_day_idx:
# Earnings day
prices.append(
{
"date": earnings_date,
"open": round(earnings_open, 2),
"high": round(max(earnings_open, earnings_close) * 1.01, 2),
"low": round(min(earnings_open, earnings_close) * 0.99, 2),
"close": round(earnings_close, 2),
"volume": volume * 3, # High volume on earnings day
}
)
elif i == earnings_day_idx + 1:
# Day after earnings
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": round(post_open, 2),
"high": round(post_open * 1.01, 2),
"low": round(post_open * 0.99, 2),
"close": round(post_open * 1.005, 2),
"volume": volume * 2,
}
)
else:
# Days after earnings
base = post_open * (1 + 0.001 * (i - earnings_day_idx - 1))
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"open": round(base * 0.999, 2),
"high": round(base * 1.01, 2),
"low": round(base * 0.99, 2),
"close": round(base, 2),
"volume": volume,
}
)
# Reverse to most-recent-first
prices.reverse()
return prices
# ===========================================================================
# Gap Size Calculator Tests
# ===========================================================================
class TestGapSizeCalculator:
"""Test gap calculation for BMO, AMC, and unknown timings."""
def _make_simple_prices(self):
"""Build simple 5-bar price data with earnings on bar index 2 (most-recent-first)."""
return [
{"date": "2025-01-07", "open": 108.0, "close": 109.0, "volume": 1000000},
{"date": "2025-01-06", "open": 107.0, "close": 108.0, "volume": 1000000},
{"date": "2025-01-05", "open": 106.0, "close": 107.0, "volume": 3000000},
{"date": "2025-01-04", "open": 100.0, "close": 100.0, "volume": 1000000},
{"date": "2025-01-03", "open": 99.0, "close": 99.5, "volume": 1000000},
]
def test_bmo_gap_positive(self):
"""BMO: gap = open[earnings_date] / close[prev_day] - 1"""
prices = self._make_simple_prices()
# Earnings on 2025-01-05 (idx 2), prev_day is 2025-01-04 (idx 3)
# BMO gap = open[2025-01-05] / close[2025-01-04] - 1 = 106/100 - 1 = 6%
result = calculate_gap(prices, "2025-01-05", "bmo")
assert result["gap_pct"] == 6.0
assert result["gap_type"] == "up"
assert result["base_price"] == 100.0
assert result["gap_price"] == 106.0
assert result["timing_used"] == "bmo"
assert result["score"] == 70.0 # >= 5%
def test_amc_gap_positive(self):
"""AMC: gap = open[next_day] / close[earnings_date] - 1"""
prices = self._make_simple_prices()
# Earnings on 2025-01-05 (idx 2), next_day is 2025-01-06 (idx 1)
# AMC gap = open[2025-01-06] / close[2025-01-05] - 1 = 107/107 - 1 = 0%
result = calculate_gap(prices, "2025-01-05", "amc")
assert abs(result["gap_pct"]) < 1.0
assert result["timing_used"] == "amc"
def test_unknown_gap_uses_amc_logic(self):
"""Unknown timing uses AMC logic."""
prices = self._make_simple_prices()
result_amc = calculate_gap(prices, "2025-01-05", "amc")
result_unknown = calculate_gap(prices, "2025-01-05", "unknown")
assert result_amc["gap_pct"] == result_unknown["gap_pct"]
def test_negative_gap(self):
"""Test negative (gap down) calculation."""
prices = [
{"date": "2025-01-06", "open": 93.0, "close": 92.0, "volume": 2000000},
{"date": "2025-01-05", "open": 95.0, "close": 94.0, "volume": 3000000},
{"date": "2025-01-04", "open": 100.0, "close": 100.0, "volume": 1000000},
]
# BMO gap = 95/100 - 1 = -5%
result = calculate_gap(prices, "2025-01-05", "bmo")
assert result["gap_pct"] == -5.0
assert result["gap_type"] == "down"
assert result["score"] == 70.0 # |gap| >= 5%
def test_score_threshold_10pct(self):
"""Gap >= 10% scores 100."""
prices = [
{"date": "2025-01-06", "open": 115.0, "close": 116.0, "volume": 1000000},
{"date": "2025-01-05", "open": 112.0, "close": 113.0, "volume": 3000000},
{"date": "2025-01-04", "open": 100.0, "close": 100.0, "volume": 1000000},
]
result = calculate_gap(prices, "2025-01-05", "bmo")
assert result["gap_pct"] == 12.0
assert result["score"] == 100.0
def test_score_threshold_7pct(self):
"""Gap >= 7% scores 85."""
prices = [
{"date": "2025-01-05", "open": 107.0, "close": 108.0, "volume": 3000000},
{"date": "2025-01-04", "open": 100.0, "close": 100.0, "volume": 1000000},
]
result = calculate_gap(prices, "2025-01-05", "bmo")
assert result["gap_pct"] == 7.0
assert result["score"] == 85.0
def test_score_threshold_3pct(self):
"""Gap >= 3% scores 55."""
prices = [
{"date": "2025-01-05", "open": 103.0, "close": 104.0, "volume": 3000000},
{"date": "2025-01-04", "open": 100.0, "close": 100.0, "volume": 1000000},
]
result = calculate_gap(prices, "2025-01-05", "bmo")
assert result["gap_pct"] == 3.0
assert result["score"] == 55.0
def test_score_threshold_1pct(self):
"""Gap >= 1% scores 35."""
prices = [
{"date": "2025-01-05", "open": 101.0, "close": 102.0, "volume": 3000000},
{"date": "2025-01-04", "open": 100.0, "close": 100.0, "volume": 1000000},
]
result = calculate_gap(prices, "2025-01-05", "bmo")
assert result["gap_pct"] == 1.0
assert result["score"] == 35.0
def test_score_threshold_below_1pct(self):
"""Gap < 1% scores 15."""
prices = [
{"date": "2025-01-05", "open": 100.5, "close": 101.0, "volume": 3000000},
{"date": "2025-01-04", "open": 100.0, "close": 100.0, "volume": 1000000},
]
result = calculate_gap(prices, "2025-01-05", "bmo")
assert result["gap_pct"] == 0.5
assert result["score"] == 15.0
def test_earnings_date_not_found(self):
"""Missing earnings date returns score 0 with warning."""
prices = _make_prices(10)
result = calculate_gap(prices, "2099-12-31", "bmo")
assert result["score"] == 0.0
assert "warning" in result
# ===========================================================================
# Pre-Earnings Trend Calculator Tests
# ===========================================================================
class TestPreEarningsTrend:
"""Test 20-day pre-earnings return calculation."""
def test_positive_trend(self):
"""Stock up 10% over 20 days before earnings."""
# Build prices where earnings date close = 110, 20 days prior close = 100
prices = []
for i in range(50):
if i == 5:
prices.append({"date": "2025-01-10", "close": 110.0, "volume": 1000000})
elif i == 25:
prices.append({"date": "2025-01-01", "close": 100.0, "volume": 1000000})
else:
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"close": 105.0,
"volume": 1000000,
}
)
result = calculate_pre_earnings_trend(prices, "2025-01-10")
assert result["return_20d_pct"] == 10.0
assert result["trend_direction"] == "up"
assert result["score"] == 85.0 # >= 10%
def test_negative_trend(self):
"""Stock down 8% over 20 days before earnings."""
prices = []
for i in range(50):
if i == 5:
prices.append({"date": "2025-01-10", "close": 92.0, "volume": 1000000})
elif i == 25:
prices.append({"date": "2025-01-01", "close": 100.0, "volume": 1000000})
else:
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"close": 96.0,
"volume": 1000000,
}
)
result = calculate_pre_earnings_trend(prices, "2025-01-10")
assert result["return_20d_pct"] == -8.0
assert result["trend_direction"] == "down"
assert result["score"] == 15.0 # < -5%
def test_score_threshold_15pct(self):
"""Return >= 15% scores 100."""
prices = []
for i in range(50):
if i == 5:
prices.append({"date": "2025-01-10", "close": 115.5, "volume": 1000000})
elif i == 25:
prices.append({"date": "2025-01-01", "close": 100.0, "volume": 1000000})
else:
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"close": 107.0,
"volume": 1000000,
}
)
result = calculate_pre_earnings_trend(prices, "2025-01-10")
assert result["score"] == 100.0
def test_score_threshold_0pct(self):
"""Return >= 0% but < 5% scores 50."""
prices = []
for i in range(50):
if i == 5:
prices.append({"date": "2025-01-10", "close": 102.0, "volume": 1000000})
elif i == 25:
prices.append({"date": "2025-01-01", "close": 100.0, "volume": 1000000})
else:
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"close": 101.0,
"volume": 1000000,
}
)
result = calculate_pre_earnings_trend(prices, "2025-01-10")
assert result["score"] == 50.0
def test_score_threshold_neg5pct(self):
"""Return >= -5% but < 0% scores 30."""
prices = []
for i in range(50):
if i == 5:
prices.append({"date": "2025-01-10", "close": 97.0, "volume": 1000000})
elif i == 25:
prices.append({"date": "2025-01-01", "close": 100.0, "volume": 1000000})
else:
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"close": 98.0,
"volume": 1000000,
}
)
result = calculate_pre_earnings_trend(prices, "2025-01-10")
assert result["score"] == 30.0
def test_insufficient_data(self):
"""Less than 20 days before earnings returns warning."""
prices = [{"date": "2025-01-10", "close": 100.0, "volume": 1000000}]
for i in range(10):
prices.append(
{
"date": f"2025-01-{9 - i:02d}",
"close": 100.0,
"volume": 1000000,
}
)
result = calculate_pre_earnings_trend(prices, "2025-01-10")
assert result["score"] == 0.0
assert "warning" in result
# ===========================================================================
# Volume Trend Calculator Tests
# ===========================================================================
class TestVolumeTrend:
"""Test volume ratio calculation."""
def test_high_ratio(self):
"""20-day avg much higher than 60-day avg -> high score."""
prices = []
for i in range(80):
vol = 2000000 if i < 20 else 500000
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"close": 100.0,
"volume": vol,
}
)
# Place earnings at day 0
prices[0]["date"] = "2025-01-01"
result = calculate_volume_trend(prices, "2025-01-01")
assert result["vol_ratio_20_60"] > 1.5
assert result["score"] >= 80.0
def test_low_ratio(self):
"""20-day avg lower than 60-day avg -> low score."""
prices = []
for i in range(80):
vol = 300000 if i < 20 else 1000000
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"close": 100.0,
"volume": vol,
}
)
prices[0]["date"] = "2025-01-01"
result = calculate_volume_trend(prices, "2025-01-01")
assert result["vol_ratio_20_60"] < 1.0
assert result["score"] == 20.0
def test_score_threshold_2x(self):
"""Ratio >= 2.0 scores 100."""
prices = []
for i in range(80):
vol = 3000000 if i < 20 else 500000
prices.append(
{
"date": f"2025-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
"close": 100.0,
"volume": vol,
}
)
prices[0]["date"] = "2025-01-01"
result = calculate_volume_trend(prices, "2025-01-01")
assert result["vol_ratio_20_60"] >= 2.0
assert result["score"] == 100.0
def test_earnings_date_not_found(self):
"""Missing date returns score 0."""
prices = _make_prices(80)
result = calculate_volume_trend(prices, "2099-12-31")
assert result["score"] == 0.0
assert "warning" in result
# ===========================================================================
# MA200 Calculator Tests
# ===========================================================================
class TestMA200Calculator:
"""Test MA200 position calculation."""
def test_above_ma200(self):
"""Price well above MA200 -> high score."""
# All closes at 100, then current close at 120 (20% above)
prices = [{"close": 120.0}]
for _ in range(249):
prices.append({"close": 100.0})
result = calculate_ma200_position(prices)
assert result["above_ma200"] is True
assert result["distance_pct"] > 0
assert result["score"] >= 55.0
def test_below_ma200(self):
"""Price below MA200 -> lower score."""
prices = [{"close": 90.0}]
for _ in range(249):
prices.append({"close": 100.0})
result = calculate_ma200_position(prices)
assert result["above_ma200"] is False
assert result["distance_pct"] < 0
def test_insufficient_data(self):
"""Less than 200 days returns warning and score 0."""
prices = _make_prices(100)
result = calculate_ma200_position(prices)
assert result["score"] == 0.0
assert "warning" in result
def test_score_20pct_above(self):
"""20% above MA200 scores 100."""
# MA200 = avg of 200 closes. If 199 at 100 + 1 at 120, MA ~ 100.1
# For exact 20% above: need current close = MA200 * 1.2
prices = [{"close": 120.0}]
for _ in range(199):
prices.append({"close": 100.0})
result = calculate_ma200_position(prices)
# MA200 = (120 + 199*100) / 200 = 20020/200 = 100.1
# distance = (120/100.1 - 1)*100 = 19.88%
assert result["score"] == 85.0 # >= 10%
def test_score_just_above(self):
"""Just above MA200 (0-5%) scores 55."""
prices = [{"close": 101.0}]
for _ in range(199):
prices.append({"close": 100.0})
result = calculate_ma200_position(prices)
# MA200 ~ 100.005, distance ~ 0.99%
assert result["score"] == 55.0
def test_score_below_neg5(self):
"""More than 5% below MA200 scores 15."""
prices = [{"close": 90.0}]
for _ in range(199):
prices.append({"close": 100.0})
result = calculate_ma200_position(prices)
# MA200 ~ 99.95, distance ~ -9.95%
assert result["score"] == 15.0
# ===========================================================================
# MA50 Calculator Tests
# ===========================================================================
class TestMA50Calculator:
"""Test MA50 position calculation."""
def test_above_ma50(self):
"""Price above MA50 -> positive distance."""
prices = [{"close": 110.0}]
for _ in range(59):
prices.append({"close": 100.0})
result = calculate_ma50_position(prices)
assert result["above_ma50"] is True
assert result["distance_pct"] > 0
def test_below_ma50(self):
"""Price below MA50 -> negative distance."""
prices = [{"close": 90.0}]
for _ in range(59):
prices.append({"close": 100.0})
result = calculate_ma50_position(prices)
assert result["above_ma50"] is False
assert result["distance_pct"] < 0
def test_insufficient_data(self):
"""Less than 50 days returns warning and score 0."""
prices = _make_prices(30)
result = calculate_ma50_position(prices)
assert result["score"] == 0.0
assert "warning" in result
def test_score_10pct_above(self):
"""10% above MA50 scores 100."""
prices = [{"close": 111.0}]
for _ in range(49):
prices.append({"close": 100.0})
result = calculate_ma50_position(prices)
# MA50 = (111 + 49*100)/50 = 100.22, distance ~ 10.78%
assert result["score"] == 100.0
def test_score_5pct_above(self):
"""5% above MA50 scores 80."""
prices = [{"close": 105.5}]
for _ in range(49):
prices.append({"close": 100.0})
result = calculate_ma50_position(prices)
# MA50 = (105.5 + 49*100)/50 = 100.11, distance ~ 5.39%
assert result["score"] == 80.0
def test_score_just_above(self):
"""Just above MA50 (0-5%) scores 60."""
prices = [{"close": 101.0}]
for _ in range(49):
prices.append({"close": 100.0})
result = calculate_ma50_position(prices)
# MA50 ~ 100.02, distance ~ 0.98%
assert result["score"] == 60.0
def test_score_below_neg5(self):
"""More than 5% below MA50 scores 15."""
prices = [{"close": 92.0}]
for _ in range(49):
prices.append({"close": 100.0})
result = calculate_ma50_position(prices)
# MA50 ~ 99.84, distance ~ -7.84%
assert result["score"] == 15.0
# ===========================================================================
# Scorer Tests
# ===========================================================================
class TestScorer:
"""Test composite scoring and grade assignment."""
def test_all_high_scores_grade_a(self):
"""All scores at 100 -> Grade A."""
result = calculate_composite_score(100, 100, 100, 100, 100)
assert result["composite_score"] == 100.0
assert result["grade"] == "A"
assert "Strong" in result["grade_description"]
def test_all_low_scores_grade_d(self):
"""All scores at 15 -> Grade D."""
result = calculate_composite_score(15, 15, 15, 15, 15)
assert result["composite_score"] == 15.0
assert result["grade"] == "D"
assert "Weak" in result["grade_description"]
def test_weight_sum_equals_1(self):
"""Verify component weights sum to 1.0."""
total = sum(COMPONENT_WEIGHTS.values())
assert abs(total - 1.0) < 0.001
def test_grade_b_boundary(self):
"""Score of exactly 70 -> Grade B."""
# 70 = 0.25*s1 + 0.30*s2 + 0.20*s3 + 0.15*s4 + 0.10*s5
# Use uniform 70 for all: 70*1.0 = 70
result = calculate_composite_score(70, 70, 70, 70, 70)
assert result["composite_score"] == 70.0
assert result["grade"] == "B"
def test_grade_c_boundary(self):
"""Score of exactly 55 -> Grade C."""
result = calculate_composite_score(55, 55, 55, 55, 55)
assert result["composite_score"] == 55.0
assert result["grade"] == "C"
def test_grade_a_boundary(self):
"""Score of exactly 85 -> Grade A."""
result = calculate_composite_score(85, 85, 85, 85, 85)
assert result["composite_score"] == 85.0
assert result["grade"] == "A"
def test_weakest_and_strongest_components(self):
"""Verify weakest and strongest component identification."""
result = calculate_composite_score(
gap_score=90,
trend_score=40,
volume_score=60,
ma200_score=70,
ma50_score=80,
)
assert result["weakest_component"] == "Pre-Earnings Trend"
assert result["weakest_score"] == 40
assert result["strongest_component"] == "Gap Size"
assert result["strongest_score"] == 90
def test_component_breakdown_present(self):
"""Component breakdown dict is present with all components."""
result = calculate_composite_score(80, 70, 60, 50, 40)
assert "component_breakdown" in result
assert len(result["component_breakdown"]) == 5
assert "Gap Size" in result["component_breakdown"]
assert "Pre-Earnings Trend" in result["component_breakdown"]
assert "Volume Trend" in result["component_breakdown"]
assert "MA200 Position" in result["component_breakdown"]
assert "MA50 Position" in result["component_breakdown"]
# ===========================================================================
# Report Generator Tests
# ===========================================================================
class TestReportGenerator:
"""Test JSON and Markdown report generation."""
def _make_result(self, symbol="AAPL", score=82.5, grade="B", gap_pct=6.3):
return {
"symbol": symbol,
"company_name": f"{symbol} Inc.",
"earnings_date": "2026-02-15",
"earnings_timing": "amc",
"gap_pct": gap_pct,
"composite_score": score,
"grade": grade,
"grade_description": "Good earnings reaction worth monitoring",
"guidance": "Monitor for follow-through buying.",
"weakest_component": "Volume Trend",
"strongest_component": "Gap Size",
"component_breakdown": {
"Gap Size": {"score": 85.0, "weight": 0.25, "weighted_score": 21.2},
"Pre-Earnings Trend": {"score": 70.0, "weight": 0.30, "weighted_score": 21.0},
"Volume Trend": {"score": 60.0, "weight": 0.20, "weighted_score": 12.0},
"MA200 Position": {"score": 85.0, "weight": 0.15, "weighted_score": 12.8},
"MA50 Position": {"score": 80.0, "weight": 0.10, "weighted_score": 8.0},
},
"current_price": 197.5,
"market_cap": 3_000_000_000_000,
"sector": "Technology",
"industry": "Consumer Electronics",
"components": {
"gap_size": {"gap_pct": gap_pct, "score": 85.0},
"pre_earnings_trend": {"return_20d_pct": 8.5, "score": 70.0},
"volume_trend": {"vol_ratio_20_60": 1.3, "score": 60.0},
"ma200_position": {"distance_pct": 15.0, "score": 85.0},
"ma50_position": {"distance_pct": 7.0, "score": 80.0},
},
}
def test_json_has_schema_version(self):
"""JSON output must have schema_version '1.0'."""
with tempfile.TemporaryDirectory() as tmpdir:
result = self._make_result()
json_path = os.path.join(tmpdir, "test.json")
metadata = {
"generated_at": "2026-02-21T10:00:00",
"generator": "earnings-trade-analyzer",
"generator_version": "1.0.0",
"lookback_days": 2,
"total_screened": 50,
}
generate_json_report([result], metadata, json_path)
with open(json_path) as f:
data = json.load(f)
assert data["schema_version"] == "1.0"
def test_json_has_required_fields(self):
"""JSON output has all required top-level fields."""
with tempfile.TemporaryDirectory() as tmpdir:
result = self._make_result()
json_path = os.path.join(tmpdir, "test.json")
metadata = {
"generated_at": "2026-02-21T10:00:00",
"generator": "earnings-trade-analyzer",
"generator_version": "1.0.0",
"lookback_days": 2,
"total_screened": 50,
}
generate_json_report([result], metadata, json_path)
with open(json_path) as f:
data = json.load(f)
assert "metadata" in data
assert "results" in data
assert "summary" in data
assert "schema_version" in data
def test_json_result_fields(self):
"""Each result in JSON has required fields including earnings_timing."""
with tempfile.TemporaryDirectory() as tmpdir:
result = self._make_result()
json_path = os.path.join(tmpdir, "test.json")
metadata = {"generated_at": "2026-02-21", "lookback_days": 2, "total_screened": 1}
generate_json_report([result], metadata, json_path)
with open(json_path) as f:
data = json.load(f)
r = data["results"][0]
assert r["symbol"] == "AAPL"
assert r["earnings_timing"] == "amc"
assert r["gap_pct"] == 6.3
assert r["composite_score"] == 82.5
assert r["grade"] == "B"
assert "components" in r
def test_json_summary_counts(self):
"""Summary counts grades correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
results = [
self._make_result("A1", score=90, grade="A"),
self._make_result("B1", score=75, grade="B"),
self._make_result("B2", score=72, grade="B"),
self._make_result("C1", score=60, grade="C"),
self._make_result("D1", score=40, grade="D"),
]
json_path = os.path.join(tmpdir, "test.json")
metadata = {"generated_at": "2026-02-21", "lookback_days": 2, "total_screened": 5}
generate_json_report(results, metadata, json_path)
with open(json_path) as f:
data = json.load(f)
assert data["summary"]["grade_a"] == 1
assert data["summary"]["grade_b"] == 2
assert data["summary"]["grade_c"] == 1
assert data["summary"]["grade_d"] == 1
assert data["summary"]["total"] == 5
def test_markdown_generation(self):
"""Markdown report generates without errors and contains key elements."""
with tempfile.TemporaryDirectory() as tmpdir:
result = self._make_result()
md_path = os.path.join(tmpdir, "test.md")
metadata = {
"generated_at": "2026-02-21T10:00:00",
"lookback_days": 2,
"total_screened": 1,
}
generate_markdown_report([result], metadata, md_path)
with open(md_path) as f:
content = f.read()
assert "Earnings Trade Analyzer Report" in content
assert "AAPL" in content
assert "Grade A & B Details" in content
def test_markdown_sector_distribution(self):
"""Markdown report includes sector distribution table."""
with tempfile.TemporaryDirectory() as tmpdir:
results = [
self._make_result("AAPL"),
self._make_result("MSFT"),
]
results[1]["sector"] = "Software"
md_path = os.path.join(tmpdir, "test.md")
metadata = {"generated_at": "2026-02-21", "lookback_days": 2, "total_screened": 2}
generate_markdown_report(results, metadata, md_path)
with open(md_path) as f:
content = f.read()
assert "Sector Distribution" in content
assert "Technology" in content
assert "Software" in content
def test_json_uses_all_results_for_summary(self):
"""When all_results provided, summary counts from all_results."""
with tempfile.TemporaryDirectory() as tmpdir:
all_results = [
self._make_result(f"S{i}", score=90 - i * 5, grade="A" if i == 0 else "B")
for i in range(10)
]
top_results = all_results[:3]
json_path = os.path.join(tmpdir, "test.json")
metadata = {"generated_at": "2026-02-21", "lookback_days": 2, "total_screened": 10}
generate_json_report(top_results, metadata, json_path, all_results=all_results)
with open(json_path) as f:
data = json.load(f)
assert data["summary"]["total"] == 10
assert len(data["results"]) == 3
# ===========================================================================
# FMP Client Failure Cases
# ===========================================================================
class TestFMPClient:
"""Test FMP client error handling and budget enforcement."""
@patch("fmp_client.requests.Session")
def test_api_429_retry(self, mock_session_cls):
"""429 response triggers retry, sets rate_limit_reached on second failure."""
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
# First call returns 429, second also 429 (exceeds max_retries=1)
mock_response_429 = MagicMock()
mock_response_429.status_code = 429
mock_response_429.text = "Rate limit exceeded"
mock_session.get.return_value = mock_response_429
client = FMPClient(api_key="test_key", max_api_calls=200)
result = client._rate_limited_get("http://example.com/test")
assert result is None
assert client.rate_limit_reached is True
@patch("fmp_client.requests.Session")
def test_api_timeout(self, mock_session_cls):
"""requests.Timeout returns None without crashing."""
import requests as req
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_session.get.side_effect = req.exceptions.Timeout("Connection timed out")
client = FMPClient(api_key="test_key", max_api_calls=200)
result = client._rate_limited_get("http://example.com/test")
assert result is None
def test_budget_exceeded(self):
"""After max_api_calls, subsequent calls raise ApiCallBudgetExceeded."""
client = FMPClient(api_key="test_key", max_api_calls=5)
client.api_calls_made = 5 # Simulate 5 calls already made
try:
client._rate_limited_get("http://example.com/test")
raise AssertionError("Should have raised ApiCallBudgetExceeded")
except ApiCallBudgetExceeded:
pass # Expected
def test_budget_exceeded_at_exact_limit(self):
"""Budget is checked before making the call."""
client = FMPClient(api_key="test_key", max_api_calls=3)
client.api_calls_made = 3
try:
client._rate_limited_get("http://example.com/test")
raise AssertionError("Should have raised ApiCallBudgetExceeded")
except ApiCallBudgetExceeded:
pass
def test_api_stats(self):
"""get_api_stats returns expected structure."""
client = FMPClient(api_key="test_key", max_api_calls=100)
stats = client.get_api_stats()
assert "api_calls_made" in stats
assert "max_api_calls" in stats
assert stats["max_api_calls"] == 100
# ===========================================================================
# Gap Boundary Cases
# ===========================================================================
class TestGapBoundary:
"""Test gap calculation edge cases: Friday BMO, Monday AMC, insufficient data."""
def test_friday_bmo(self):
"""Friday BMO: gap = open[Friday] / close[Thursday]."""
prices = [
{"date": "2025-01-10", "open": 112.0, "close": 113.0, "volume": 2000000}, # Friday
{"date": "2025-01-09", "open": 100.0, "close": 100.0, "volume": 1000000}, # Thursday
]
# Most-recent-first: idx 0 = Friday, idx 1 = Thursday
result = calculate_gap(prices, "2025-01-10", "bmo")
# gap = 112/100 - 1 = 12%
assert result["gap_pct"] == 12.0
assert result["gap_type"] == "up"
assert result["score"] == 100.0
def test_monday_amc(self):
"""Monday AMC: gap = open[Tuesday] / close[Monday]."""
prices = [
{"date": "2025-01-14", "open": 108.0, "close": 109.0, "volume": 2000000}, # Tuesday
{"date": "2025-01-13", "open": 100.0, "close": 100.0, "volume": 3000000}, # Monday
]
# AMC: gap = open[next_day] / close[earnings_date]
# next_day idx = 0 (Tuesday), earnings_date idx = 1 (Monday)
result = calculate_gap(prices, "2025-01-13", "amc")
# gap = 108/100 - 1 = 8%
assert result["gap_pct"] == 8.0
assert result["gap_type"] == "up"
assert result["score"] == 85.0 # >= 7%
def test_insufficient_data_no_prev_day(self):
"""BMO with no previous day returns warning."""
prices = [
{"date": "2025-01-10", "open": 112.0, "close": 113.0, "volume": 1000000},
]
result = calculate_gap(prices, "2025-01-10", "bmo")
assert result["score"] == 0.0
assert "warning" in result
def test_insufficient_data_no_next_day(self):
"""AMC with no next day returns warning."""
prices = [
{"date": "2025-01-10", "open": 100.0, "close": 100.0, "volume": 1000000},
]
result = calculate_gap(prices, "2025-01-10", "amc")
assert result["score"] == 0.0
assert "warning" in result
def test_pre_earnings_trend_insufficient_data_below_20(self):
"""Pre-earnings trend with < 20 days before earnings -> score 0 + warning."""
prices = []
for i in range(15):
prices.append(
{
"date": f"2025-01-{15 - i:02d}",
"close": 100.0,
"volume": 1000000,
}
)
result = calculate_pre_earnings_trend(prices, "2025-01-15")
assert result["score"] == 0.0
assert "warning" in result
# ===========================================================================
# Entry Filter Tests (Fix #2)
# ===========================================================================
class TestEntryFilter:
"""Test entry quality filter rules from 517-trade backtest."""
def _make_result(self, price=50.0, gap_pct=5.0, score=75.0):
return {
"current_price": price,
"gap_pct": gap_pct,
"composite_score": score,
}
def test_price_above_30_passes(self):
"""Price >= $30 passes the filter."""
results = [self._make_result(price=50.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 1
def test_price_below_10_excluded(self):
"""Price < $10 excluded (below $30 threshold)."""
results = [self._make_result(price=8.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 0
def test_price_15_excluded(self):
"""Price $15 excluded ($10-$30 low price band)."""
results = [self._make_result(price=15.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 0
def test_price_29_excluded(self):
"""Price $29 excluded (just below $30 threshold)."""
results = [self._make_result(price=29.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 0
def test_price_30_passes(self):
"""Price exactly $30 passes."""
results = [self._make_result(price=30.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 1
def test_high_gap_high_score_excluded(self):
"""Gap >= 10% AND score >= 85 excluded (paradox pattern)."""
results = [self._make_result(price=100.0, gap_pct=12.0, score=90.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 0
def test_high_gap_low_score_passes(self):
"""Gap >= 10% but score < 85 passes."""
results = [self._make_result(price=100.0, gap_pct=12.0, score=80.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 1
def test_low_gap_high_score_passes(self):
"""Gap < 10% with score >= 85 passes."""
results = [self._make_result(price=100.0, gap_pct=8.0, score=90.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 1
def test_negative_gap_excluded_by_abs(self):
"""Negative gap >= 10% AND score >= 85 also excluded."""
results = [self._make_result(price=100.0, gap_pct=-11.0, score=88.0)]
filtered = apply_entry_filter(results)
assert len(filtered) == 0
def test_mixed_results(self):
"""Mixed batch: only valid entries survive."""
results = [
self._make_result(price=100.0, gap_pct=5.0, score=80.0), # Pass
self._make_result(price=15.0, gap_pct=5.0, score=80.0), # Fail: price < $30
self._make_result(price=100.0, gap_pct=12.0, score=90.0), # Fail: gap+score
self._make_result(price=50.0, gap_pct=3.0, score=75.0), # Pass
]
filtered = apply_entry_filter(results)
assert len(filtered) == 2
"""Issue #64: stable/historical-price-eod/full normalization for earnings-trade-analyzer.
This skill's `get_historical_prices()` returns Optional[list[dict]] (NOT dict),
unlike the other 6 fmp_client implementations. The public method extracts
`data["historical"]` from the normalizer output. This test pins that contract.
"""
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", max_api_calls=200)
client.max_retries = 0
return client
def _mock_response(status_code, json_payload):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_payload
resp.text = ""
return resp
class TestEODFlatListSuccess:
@patch("fmp_client.requests.Session")
def test_get_historical_prices_returns_list(self, mock_session_class):
"""Flat list response -> public method returns list (NOT dict)."""
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)
# CRITICAL: this skill returns list, not dict
assert isinstance(result, list), (
f"expected list (skill contract), got {type(result).__name__}"
)
assert len(result) == 2
assert result[0]["date"] == "2026-04-29"
assert result[0]["close"] == 501.0
assert "symbol" not in result[0], "row-level symbol should be stripped"
# URL regression
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
"""FMP /api/v3 → /stable migration tests for earnings-trade-analyzer.
Covers the two hardcoded-v3 call sites:
- get_earnings_calendar -> /stable/earnings-calendar (underscore form 404s on
all tiers; the hyphenated name is the live one, pinned in _PATH_RENAME_NO_SYMBOL)
- get_company_profiles -> per-symbol /stable/profile (stable rejects comma
batching, so the method must issue one request per symbol)
"""
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", max_api_calls=100) # 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 TestEarningsCalendarMigration:
def test_earnings_calendar_hits_stable_hyphen(self):
client = _make_client()
seen = []
def mock_get(url, params=None, timeout=None):
seen.append((url, params or {}))
return _mock_response(200, [{"symbol": "AAPL", "date": "2026-06-01"}])
client.session.get = mock_get
result = client.get_earnings_calendar("2026-06-01", "2026-06-08")
assert len(seen) == 1
url, params = seen[0]
# underscore /stable form 404s for all tiers; the hyphenated name is the live one
assert "/stable/earnings-calendar" in url
assert "/api/v3/" not in url
assert params.get("from") == "2026-06-01"
assert params.get("to") == "2026-06-08"
assert result == [{"symbol": "AAPL", "date": "2026-06-01"}]
class TestCompanyProfilesPerSymbol:
def test_profiles_issued_one_request_per_symbol_on_stable(self):
client = _make_client()
seen = []
def mock_get(url, params=None, timeout=None):
params = params or {}
seen.append((url, params))
sym = params.get("symbol")
return _mock_response(200, [{"symbol": sym, "marketCap": 1000}])
client.session.get = mock_get
result = client.get_company_profiles(["AAPL", "MSFT"])
# Two symbols -> two separate /stable/profile?symbol= calls (no comma batch)
assert len(seen) == 2
for url, params in seen:
assert "/stable/profile" in url
assert "/api/v3/" not in url
assert "," not in params.get("symbol", "")
assert set(result.keys()) == {"AAPL", "MSFT"}
assert result["AAPL"]["marketCap"] == 1000
Related skills
How it compares
Pick earnings-trade-analyzer over dividend screeners when post-earnings gap momentum and volume accumulation matter more than dividend CAGR and RSI pullback entry.
FAQ
What factors does earnings-trade-analyzer score?
earnings-trade-analyzer weights gap size at 25%, pre-earnings 20-day trend at 30%, 20-day versus 60-day volume ratio at 20%, MA200 position at 15%, and MA50 position at 10% into a 0–100 composite score.
What letter grades does earnings-trade-analyzer assign?
earnings-trade-analyzer maps composite scores to A at 85+, B at 70–84, C at 55–69, and D below 55. Grade A setups signal strong earnings reactions worth entry consideration.
Is Earnings Trade Analyzer safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.