
Financial Analysis Stock Screening
- 100 installs
- 5 repo stars
- Updated July 24, 2026
- pionex-official/pionex-skills
Helps with ai & agent building tasks.
About
financial-analysis-stock-screening is a Claude Code skill in the AI & Agent Building category.
- financial-analysis-stock-screening
- AI & Agent Building
- AI-coding skill
Financial Analysis Stock Screening by the numbers
- 100 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,381 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pionex-official/pionex-skills --skill financial-analysis-stock-screeningAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 100 |
|---|---|
| repo stars | ★ 5 |
| Last updated | July 24, 2026 |
| Repository | pionex-official/pionex-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Stock Screening
Quantitative stock screener with composite scoring. Discovers candidates via web search, filters by thresholds, scores on growth/value/quality dimensions, and returns a ranked list with actionable picks. Data from SEC EDGAR (financials), Yahoo Finance (market data), and web search (universe discovery).
IMPORTANT: This skill requires running `bash run.sh` to produce scores. You MUST execute the script and use its JSON output — do not skip it or compute metrics manually. The script returns growth/value/quality scores (0-100) and composite rankings that must appear in the output.
Setup
No dependencies required. All scripts use Python standard library only.
Workflow
Step 1 — Clarify criteria
Before running any script, understand:
- Universe: sector, theme, or broad market?
- Style: growth (high revenue/earnings growth), value (cheap multiples), or quality (high margins)?
- Thresholds: e.g. "revenue growth >20%", "P/E below 25x", "margin >30%"
If the user gives a vague request like "find me good tech stocks", default to growth style and state the assumption. If the user remains vague after clarification (no specific style or thresholds), default to growth + quality: rank by revenue growth first, then net margin as tiebreaker. State this explicitly so the user can adjust.
Step 2 — Build the candidate universe
Use web search to identify the relevant stock universe:
top [sector] stocks by market cap [year][theme] stocks list [year](e.g. "AI stocks list 2026")S&P 500 [sector] constituents
Common universes for reference:
| Theme | Example symbols |
|---|---|
| Magnificent 7 | AAPL, MSFT, GOOGL, AMZN, NVDA, META, TSLA |
| Semiconductors | NVDA, AMD, INTC, AVGO, QCOM, TXN, MRVL, MU |
| AI concept | NVDA, MSFT, GOOGL, META, AMZN, CRM, PLTR, SNOW |
| EV / Clean Energy | TSLA, RIVN, LCID, NIO, ENPH, FSLR, PLUG |
These are reference examples — always verify via web search for the current year, as index constituents and thematic groupings change over time.
Narrow to max 10 symbols before running the screener. State which symbols were excluded and why.
Step 3 — Run the screener
Always run the screener script — do not compute scores or filter manually. The script produces standardized scores, rankings, and filter results that must be used in Step 4.
bash run.sh <SYM1> <SYM2> ... <SYM10> --style <growth|value|quality>
# With filters:
bash run.sh <SYMS> --style growth --min-growth 10 --min-margin 15 --max-pe 40Scoring system:
Each company is scored 0-100 on three dimensions:
- Growth score (60% revenue growth + 40% net income growth)
- Value score (50% P/E + 50% P/S — lower multiples score higher)
- Quality score (50% net margin + 50% operating margin)
Composite score is weighted by style:
growth: 50% growth + 30% quality + 20% valuevalue: 50% value + 30% quality + 20% growthquality: 50% quality + 30% growth + 20% value
Threshold filters (optional):
--min-growth N: exclude companies with revenue growth < N%--min-margin N: exclude companies with net margin < N%--max-pe N: exclude companies with P/E > Nx
Step 4 — Present ranked results
Use the JSON output from get_screen.py directly — present the scores, rank, and filtered_out fields as-is. Do not invent your own scoring system (no star ratings, no PEG-based rankings). The script's composite score is the authoritative ranking.
Lead with screen summary:
Screen: [Style] — [Sector/Theme]
Universe: [N] candidates → [M] passed filters
Ranked by: composite score ([style] weighted)Then ranked table (sorted by composite score):
| Rank | Symbol | Revenue | Rev Growth | Net Margin | P/E | Growth | Value | Quality | Composite |
|---|---|---|---|---|---|---|---|---|---|
| 1 | NVDA | $130B | +114% | 55.8% | 35.8x | 98.2 | 42.1 | 89.5 | 84.7 |
| 2 | META | $162B | +22% | 35.6% | 25.4x | 72.1 | 68.3 | 72.0 | 71.2 |
Then Top 3 picks:
1. [TICKER] — [One-line thesis] (Composite: XX.X)
[Why it ranks highest — which scores drive the result]
[Key risk or caveat]Filtered out (if any):
Excluded: [TICKER] (rev growth 5.2% < 10% threshold)Step 5 — Deep dive (if user wants)
For top picks, validate with historical trend:
bash run.sh <SYMBOL> --style quality # re-run with single symbol for detailCheck: is the metric improving over time or a one-time event?
Optionally, validate the pick against its sector peers using the comps-analysis skill for full statistical benchmarking.
---
Output Format
Sections in order: 1. Screen summary box 2. Ranked table with scores 3. Top 3 picks with thesis 4. Filtered out / excluded (if any) 5. Caveats
Close with caveats:
- Scores are relative within this peer group — adding/removing a company changes all scores
- Screens surface candidates, not conclusions — each pick needs further validation
- SEC data is annual (10-K); recent quarterly shifts may not be reflected
---
Formatting Rules
- Revenue: B or M, e.g. "$416B"
- Margins and growth: one decimal, e.g. "26.9%", "+15.7%"
- Multiples: one decimal, e.g. "28.4x"
- Scores: one decimal, e.g. "84.7"
Limitations
- Relative scoring: scores are only meaningful within the screened group, not absolute
- No real-time price data: P/E and P/S depend on Yahoo Finance availability
- US stocks only: SEC EDGAR covers US-listed equities
- Annual data: 10-K by default; quarterly shifts may not be reflected
- No dividend data: For income-style screening, dividend yield must come from web search
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/scripts/get_screen.py" "$@"
#!/usr/bin/env python3
"""
Fetch quarterly and annual financial statements from SEC EDGAR XBRL API.
Usage:
python get_fundamentals.py <SYMBOL> [--form 10-K|10-Q] [--periods N]
Output: JSON with an array of financial periods.
Data source: SEC EDGAR (https://data.sec.gov)
No API key required. No external dependencies.
Rate limited to ~8 req/s per SEC guidelines.
Important: 10-Q data is YTD cumulative. Derive standalone quarters by subtraction:
Q1 standalone = Q1 YTD
Q2 standalone = H1 YTD - Q1 YTD
Q3 standalone = 9M YTD - H1 YTD
Q4 standalone = Full-year (10-K) - 9M YTD
"""
import argparse
import json
import sys
import time
import urllib.request
from datetime import datetime
BASE_URL = "https://data.sec.gov"
TICKER_URL = "https://www.sec.gov/files/company_tickers.json"
USER_AGENT = "finchat-skills contact@finchat.ai"
MIN_REQUEST_INTERVAL = 0.12 # ~8 req/s
_last_request_at = 0.0
_ticker_cik_map = None
def _throttle():
global _last_request_at
elapsed = time.time() - _last_request_at
if elapsed < MIN_REQUEST_INTERVAL:
time.sleep(MIN_REQUEST_INTERVAL - elapsed)
_last_request_at = time.time()
def _get_json(url: str) -> dict:
_throttle()
req = urllib.request.Request(url)
req.add_header("User-Agent", USER_AGENT)
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except (urllib.request.URLError, urllib.error.HTTPError) as e:
raise RuntimeError(f"SEC EDGAR request failed for {url}: {e}")
def _ensure_ticker_map() -> dict[str, int]:
global _ticker_cik_map
if _ticker_cik_map is not None:
return _ticker_cik_map
raw = _get_json(TICKER_URL)
_ticker_cik_map = {}
for entry in raw.values():
_ticker_cik_map[entry["ticker"].upper()] = entry["cik_str"]
return _ticker_cik_map
def _lookup_cik(ticker: str) -> int:
m = _ensure_ticker_map()
cik = m.get(ticker.upper())
if cik is None:
raise ValueError(f"Ticker {ticker!r} not found in SEC EDGAR")
return int(cik)
# ── XBRL parsing ──
def _first_concept(usgaap: dict, *names: str):
"""Return the concept with the most recent data among the given names."""
best = None
best_end = ""
for name in names:
concept = usgaap.get(name)
if not concept:
continue
units = concept.get("units", {})
for unit_facts in units.values():
for f in unit_facts:
end = f.get("end", "")
if end > best_end:
best_end = end
best = concept
break
return best
def _facts_for_form(concept, form: str) -> dict[str, float]:
"""Extract period_end -> value map for a filing form type."""
m = {}
if not concept:
return m
units = concept.get("units", {})
for unit_facts in units.values():
for f in unit_facts:
if f.get("form") == form:
end = f.get("end", "")
if end not in m:
m[end] = f.get("val", 0)
break
return m
def _anchor_periods(concept, form: str, n: int) -> list[dict]:
"""Return the most recent N filing facts for a form type."""
if not concept:
return []
facts = []
units = concept.get("units", {})
for unit_facts in units.values():
for f in unit_facts:
if f.get("form") == form:
facts.append(f)
break
facts.sort(key=lambda f: f.get("end", ""), reverse=True)
seen = set()
deduped = []
for f in facts:
end = f.get("end", "")
if end not in seen:
seen.add(end)
deduped.append(f)
return deduped[:n] if n > 0 else deduped
def _fiscal_quarter_from_period(start: str, end: str, form: str) -> tuple[int, bool]:
"""Derive fiscal quarter and YTD flag from filing dates.
10-K -> quarter=4, isYTD=False
10-Q ~3mo -> Q1, ~6mo -> Q2, ~9mo -> Q3, all isYTD=True
"""
if form == "10-K":
return 4, False
if not start or not end:
return 0, True
try:
t0 = datetime.strptime(start, "%Y-%m-%d")
t1 = datetime.strptime(end, "%Y-%m-%d")
except ValueError:
return 0, True
days = (t1 - t0).days
if days < 105:
return 1, True
elif days < 196:
return 2, True
else:
return 3, True
def _period_label(start: str, end: str, fy: int, fq: int, form: str) -> str:
"""Build human-readable label, e.g. 'FY2025 Q1 (Oct-Dec 2024)'."""
if not start or not end:
return f"FY{fy} Annual" if form == "10-K" else f"FY{fy} Q{fq}"
try:
t0 = datetime.strptime(start, "%Y-%m-%d")
t1 = datetime.strptime(end, "%Y-%m-%d")
except ValueError:
return f"FY{fy} Annual" if form == "10-K" else f"FY{fy} Q{fq}"
s = t0.strftime("%b %Y")
e = t1.strftime("%b %Y")
if form == "10-K":
return f"FY{fy} Annual ({s}\u2013{e})"
return f"FY{fy} Q{fq} ({s}\u2013{e})"
def _eps_for_period(eps: float, is_ytd: bool, quarter: int):
"""Return EPS only when directly usable as standalone figure.
Q2/Q3 YTD EPS is NOT additive — zeroed to prevent misuse."""
if not is_ytd:
return eps # 10-K full-year
if quarter == 1:
return eps # Q1 YTD == Q1 standalone
return None # Q2/Q3: must compute from standalone net_income / shares
def _derived_gross_profit(gp, revenue, cost_of_revenue):
if gp is not None:
return gp
if revenue and cost_of_revenue:
return revenue - cost_of_revenue
return None
def _normalize_shares_scale(shares, net_income, eps):
"""Correct for companies reporting shares in thousands or millions."""
if not shares or shares <= 0 or not eps or not net_income:
return shares if shares else None
implied = abs(net_income) / abs(eps)
ratio = implied / shares
if 500 <= ratio < 5000:
return shares * 1000
elif 500_000 <= ratio < 5_000_000:
return shares * 1_000_000
return shares
# ── Main fetch ──
def fetch_fundamentals(symbol: str, form: str = "10-K", periods: int = 4) -> dict:
symbol = symbol.upper()
if periods <= 0:
periods = 4
elif periods > 8:
periods = 8
cik = _lookup_cik(symbol)
url = f"{BASE_URL}/api/xbrl/companyfacts/CIK{cik:010d}.json"
raw = _get_json(url)
entity_name = raw.get("entityName", symbol)
usgaap = raw.get("facts", {}).get("us-gaap")
if not usgaap:
return {"symbol": symbol, "entity_name": entity_name, "error": "No US-GAAP data available", "periods": []}
rev_concept = _first_concept(usgaap,
"Revenues",
"RevenueFromContractWithCustomerExcludingAssessedTax",
"RevenueFromContractWithCustomerIncludingAssessedTax",
"SalesRevenueNet",
"SalesRevenueGoodsNet",
)
anchor_concept = rev_concept or usgaap.get("NetIncomeLoss")
if not anchor_concept:
return {"symbol": symbol, "entity_name": entity_name, "error": "No financial data found", "periods": []}
anchors = _anchor_periods(anchor_concept, form, periods)
if not anchors:
return {"symbol": symbol, "entity_name": entity_name, "error": f"No {form} filings found", "periods": []}
# Build period->value lookup maps
rev_map = _facts_for_form(rev_concept, form)
ni_map = _facts_for_form(_first_concept(usgaap, "NetIncomeLoss", "NetIncomeLossAttributableToParent", "ProfitLoss"), form)
gp_map = _facts_for_form(_first_concept(usgaap, "GrossProfit", "GrossProfitLoss"), form)
cor_map = _facts_for_form(_first_concept(usgaap, "CostOfRevenue", "CostOfGoodsAndServicesSold", "CostOfGoodsSold"), form)
oi_map = _facts_for_form(_first_concept(usgaap, "OperatingIncomeLoss"), form)
eps_map = _facts_for_form(_first_concept(usgaap, "EarningsPerShareDiluted"), form)
ocf_map = _facts_for_form(_first_concept(usgaap, "NetCashProvidedByUsedInOperatingActivities"), form)
capex_map = _facts_for_form(_first_concept(usgaap,
"PaymentsToAcquirePropertyPlantAndEquipment",
"PaymentsForCapitalImprovements",
"PaymentsToAcquireOtherProductiveAssets",
), form)
assets_map = _facts_for_form(usgaap.get("Assets"), form)
liab_map = _facts_for_form(usgaap.get("Liabilities"), form)
equity_map = _facts_for_form(_first_concept(usgaap,
"StockholdersEquity",
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest",
), form)
ltd_map = _facts_for_form(_first_concept(usgaap, "LongTermDebt", "LongTermDebtNoncurrent"), form)
interest_map = _facts_for_form(_first_concept(usgaap, "InterestExpense", "InterestAndDebtExpense"), form)
tax_map = _facts_for_form(_first_concept(usgaap, "IncomeTaxExpenseBenefit"), form)
pretax_map = _facts_for_form(_first_concept(usgaap,
"IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest",
"IncomeLossFromContinuingOperationsBeforeIncomeTaxesMinorityInterestAndIncomeLossFromEquityMethodInvestments",
), form)
cash_map = _facts_for_form(_first_concept(usgaap,
"CashAndCashEquivalentsAtCarryingValue",
"CashCashEquivalentsAndShortTermInvestments",
"CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents",
), form)
shares_map = _facts_for_form(_first_concept(usgaap,
"CommonStockSharesOutstanding",
"WeightedAverageNumberOfDilutedSharesOutstanding",
), form)
results = []
for af in anchors:
end = af.get("end", "")
start = af.get("start", "")
fy = af.get("fy", 0)
q, is_ytd = _fiscal_quarter_from_period(start, end, form)
raw_eps = eps_map.get(end, 0)
eps = _eps_for_period(raw_eps, is_ytd, q)
net_income = ni_map.get(end, 0)
shares = _normalize_shares_scale(shares_map.get(end, 0), net_income, raw_eps)
revenue = rev_map.get(end, 0)
gross_profit = _derived_gross_profit(gp_map.get(end), revenue, cor_map.get(end, 0))
period = {
"period": end,
"form": form,
"fiscal_year": fy,
"fiscal_quarter": q,
"is_ytd": is_ytd,
"period_label": _period_label(start, end, fy, q, form),
"revenue": revenue if revenue else None,
"gross_profit": gross_profit,
"operating_income": oi_map.get(end),
"net_income": net_income if net_income else None,
"eps_diluted": eps,
"pretax_income": pretax_map.get(end),
"interest_expense": interest_map.get(end),
"income_tax_expense": tax_map.get(end),
"total_assets": assets_map.get(end),
"total_liabilities": liab_map.get(end),
"shareholders_equity": equity_map.get(end),
"cash_and_equivalents": cash_map.get(end),
"long_term_debt": ltd_map.get(end),
"shares_outstanding": shares,
"operating_cash_flow": ocf_map.get(end),
"capital_expenditure": capex_map.get(end),
}
results.append(period)
return {
"symbol": symbol,
"entity_name": entity_name,
"form": form,
"periods": results,
}
def main():
parser = argparse.ArgumentParser(description="Fetch financial fundamentals from SEC EDGAR")
parser.add_argument("symbol", help="Stock ticker symbol (e.g., AAPL)")
parser.add_argument("--form", choices=["10-K", "10-Q"], default="10-K", help="Filing type (default: 10-K)")
parser.add_argument("--periods", type=int, default=4, help="Number of periods to fetch (default: 4)")
args = parser.parse_args()
result = fetch_fundamentals(args.symbol.upper(), args.form, args.periods)
print(json.dumps(result, indent=2, default=str))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Fetch current stock quote from Yahoo Finance.
Usage:
python get_quote.py AAPL [MSFT GOOGL ...]
Output: JSON with current price, market cap, shares outstanding, PE, etc.
No pip dependencies — uses Yahoo Finance endpoints via urllib with cookie+crumb auth.
"""
import argparse
import json
import sys
import urllib.request
import urllib.error
import http.cookiejar
YF_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=1d&interval=1d"
YF_CRUMB_URL = "https://query2.finance.yahoo.com/v1/test/getcrumb"
YF_SUMMARY_URL = "https://query2.finance.yahoo.com/v10/finance/quoteSummary/{symbol}?modules=price,defaultKeyStatistics,summaryDetail&crumb={crumb}"
_opener = None
_crumb = None
def _init_session():
"""Initialize cookie jar and fetch crumb (one-time per process)."""
global _opener, _crumb
if _opener is not None:
return
cj = http.cookiejar.CookieJar()
_opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
_opener.addheaders = [("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)")]
# Step 1: hit a Yahoo page to get cookies
try:
_opener.open("https://fc.yahoo.com/", timeout=10)
except urllib.error.HTTPError:
pass # 404 is expected, but cookies are set
except Exception:
pass
# Step 2: fetch crumb using those cookies
try:
resp = _opener.open(YF_CRUMB_URL, timeout=10)
_crumb = resp.read().decode("utf-8").strip()
except Exception:
_crumb = None
def _yf_get(url: str, use_session: bool = False) -> dict | None:
"""Fetch JSON from Yahoo Finance."""
try:
if use_session and _opener:
resp = _opener.open(url, timeout=10)
else:
req = urllib.request.Request(url)
req.add_header("User-Agent", "Mozilla/5.0")
resp = urllib.request.urlopen(req, timeout=10)
return json.loads(resp.read())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError):
return None
def _raw_val(obj):
"""Extract raw value from Yahoo Finance formatted object like {'raw': 1.23, 'fmt': '1.23'}."""
if isinstance(obj, dict):
return obj.get("raw")
return obj
def fetch_quote(symbols: list[str]) -> dict:
_init_session()
quotes = []
for sym in symbols:
sym = sym.upper()
try:
# Chart endpoint for price (reliable, no auth needed)
data = _yf_get(YF_CHART_URL.format(symbol=sym))
if not data:
quotes.append({"symbol": sym, "error": "Failed to fetch quote"})
continue
result_list = data.get("chart", {}).get("result")
if not result_list:
quotes.append({"symbol": sym, "error": "No chart data"})
continue
meta = result_list[0].get("meta", {})
quote = {
"symbol": sym,
"name": meta.get("longName") or meta.get("shortName") or sym,
"current_price": meta.get("regularMarketPrice"),
"previous_close": meta.get("previousClose") or meta.get("chartPreviousClose"),
"market_cap": None,
"shares_outstanding": None,
"enterprise_value": None,
"volume": meta.get("regularMarketVolume"),
"fifty_two_week_high": meta.get("fiftyTwoWeekHigh"),
"fifty_two_week_low": meta.get("fiftyTwoWeekLow"),
"pe_trailing": None,
"pe_forward": None,
"eps_trailing": None,
"beta": None,
"dividend_yield": None,
"currency": meta.get("currency"),
"exchange": meta.get("exchangeName"),
}
# Try quoteSummary with crumb for richer data
if _crumb:
summary = _yf_get(
YF_SUMMARY_URL.format(symbol=sym, crumb=urllib.request.quote(_crumb)),
use_session=True,
)
if summary:
modules = summary.get("quoteSummary", {}).get("result")
if modules:
mod = modules[0]
price = mod.get("price", {})
stats = mod.get("defaultKeyStatistics", {})
detail = mod.get("summaryDetail", {})
quote["name"] = _raw_val(price.get("longName")) or price.get("shortName") or quote["name"]
quote["market_cap"] = _raw_val(price.get("marketCap"))
quote["shares_outstanding"] = _raw_val(stats.get("sharesOutstanding")) or _raw_val(price.get("sharesOutstanding"))
quote["enterprise_value"] = _raw_val(stats.get("enterpriseValue"))
quote["pe_trailing"] = _raw_val(detail.get("trailingPE"))
quote["pe_forward"] = _raw_val(stats.get("forwardPE"))
quote["eps_trailing"] = _raw_val(stats.get("trailingEps"))
quote["beta"] = _raw_val(stats.get("beta"))
quote["dividend_yield"] = _raw_val(detail.get("dividendYield"))
quotes.append(quote)
except Exception as e:
quotes.append({"symbol": sym, "error": str(e)})
return {"quotes": quotes}
def main():
parser = argparse.ArgumentParser(description="Fetch stock quotes")
parser.add_argument("symbols", nargs="+", help="Stock symbols")
args = parser.parse_args()
result = fetch_quote([s.upper() for s in args.symbols])
print(json.dumps(result, indent=2, default=str))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Stock screener with composite scoring and threshold filtering.
Usage:
python get_screen.py AAPL MSFT GOOGL NVDA META
python get_screen.py NVDA AMD AVGO QCOM --style growth
python get_screen.py AAPL MSFT GOOGL --style value --min-margin 20 --max-pe 35
Styles: growth (default), value, quality
Output: JSON with scored and ranked companies.
No pip dependencies — uses SEC EDGAR + Yahoo Finance via urllib.
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from get_fundamentals import fetch_fundamentals
from get_quote import fetch_quote
def _normalize_score(value: float, values: list[float], higher_is_better: bool = True) -> float:
"""Normalize value to 0-100 score within the group."""
if not values or len(values) < 2:
return 50.0
lo, hi = min(values), max(values)
if hi == lo:
return 50.0
raw = (value - lo) / (hi - lo) * 100
return round(raw if higher_is_better else 100 - raw, 1)
def fetch_screen(symbols: list[str], style: str = "growth",
min_growth: float | None = None, min_margin: float | None = None,
max_pe: float | None = None) -> dict:
if len(symbols) > 10:
return {"error": "Maximum 10 symbols allowed", "companies": []}
# Batch fetch quotes
quote_map = {}
try:
quote_result = fetch_quote([s.upper() for s in symbols])
for q in quote_result.get("quotes", []):
if "error" not in q:
quote_map[q["symbol"]] = q
except Exception:
pass
raw_companies = []
for sym in symbols:
sym = sym.upper()
try:
data = fetch_fundamentals(sym, form="10-K", periods=2)
if data.get("error"):
raw_companies.append({"symbol": sym, "error": data["error"]})
continue
periods = data.get("periods", [])
if not periods:
raw_companies.append({"symbol": sym, "error": "No annual data"})
continue
latest = periods[0]
prior = periods[1] if len(periods) > 1 else None
revenue = latest.get("revenue")
operating_income = latest.get("operating_income")
net_income = latest.get("net_income")
eps = latest.get("eps_diluted")
gross_profit = latest.get("gross_profit")
gross_margin = (gross_profit / revenue * 100) if revenue and gross_profit else None
op_margin = (operating_income / revenue * 100) if revenue and operating_income else None
net_margin = (net_income / revenue * 100) if revenue and net_income else None
prior_rev = prior.get("revenue") if prior else None
prior_ni = prior.get("net_income") if prior else None
rev_growth = ((revenue - prior_rev) / abs(prior_rev) * 100) if revenue and prior_rev and prior_rev != 0 else None
ni_growth = ((net_income - prior_ni) / abs(prior_ni) * 100) if net_income is not None and prior_ni and prior_ni != 0 else None
q = quote_map.get(sym, {})
market_cap = q.get("market_cap")
pe = (market_cap / net_income) if market_cap and net_income and net_income > 0 else None
ps = (market_cap / revenue) if market_cap and revenue and revenue > 0 else None
raw_companies.append({
"symbol": sym,
"entity_name": data.get("entity_name", sym),
"fiscal_year": latest.get("fiscal_year"),
"market_cap": market_cap,
"revenue": revenue,
"net_income": net_income,
"eps_diluted": eps,
"gross_margin_pct": round(gross_margin, 1) if gross_margin is not None else None,
"operating_margin_pct": round(op_margin, 1) if op_margin is not None else None,
"net_margin_pct": round(net_margin, 1) if net_margin is not None else None,
"revenue_growth_yoy_pct": round(rev_growth, 1) if rev_growth is not None else None,
"net_income_growth_yoy_pct": round(ni_growth, 1) if ni_growth is not None else None,
"pe_ratio": round(pe, 1) if pe is not None else None,
"ps_ratio": round(ps, 1) if ps is not None else None,
})
except Exception as e:
raw_companies.append({"symbol": sym, "error": str(e)})
# Separate valid companies from errors
valid = [c for c in raw_companies if "error" not in c]
errors = [c for c in raw_companies if "error" in c]
# Apply threshold filters
filtered_out = []
passed = []
for c in valid:
reasons = []
if min_growth is not None and (c["revenue_growth_yoy_pct"] is None or c["revenue_growth_yoy_pct"] < min_growth):
reasons.append(f"rev growth {c['revenue_growth_yoy_pct']}% < {min_growth}%")
if min_margin is not None and (c["net_margin_pct"] is None or c["net_margin_pct"] < min_margin):
reasons.append(f"net margin {c['net_margin_pct']}% < {min_margin}%")
if max_pe is not None and c["pe_ratio"] is not None and c["pe_ratio"] > max_pe:
reasons.append(f"P/E {c['pe_ratio']}x > {max_pe}x")
if reasons:
filtered_out.append({"symbol": c["symbol"], "reasons": reasons})
else:
passed.append(c)
# Compute scores for passed companies
if passed:
rev_growths = [c["revenue_growth_yoy_pct"] for c in passed if c["revenue_growth_yoy_pct"] is not None]
ni_growths = [c["net_income_growth_yoy_pct"] for c in passed if c["net_income_growth_yoy_pct"] is not None]
net_margins = [c["net_margin_pct"] for c in passed if c["net_margin_pct"] is not None]
op_margins = [c["operating_margin_pct"] for c in passed if c["operating_margin_pct"] is not None]
pes = [c["pe_ratio"] for c in passed if c["pe_ratio"] is not None]
pss = [c["ps_ratio"] for c in passed if c["ps_ratio"] is not None]
for c in passed:
# Growth score (higher growth = better)
g1 = _normalize_score(c["revenue_growth_yoy_pct"], rev_growths) if c["revenue_growth_yoy_pct"] is not None else 50
g2 = _normalize_score(c["net_income_growth_yoy_pct"], ni_growths) if c["net_income_growth_yoy_pct"] is not None else 50
growth_score = round(g1 * 0.6 + g2 * 0.4, 1)
# Value score (lower multiples = better)
v1 = _normalize_score(c["pe_ratio"], pes, higher_is_better=False) if c["pe_ratio"] is not None else 50
v2 = _normalize_score(c["ps_ratio"], pss, higher_is_better=False) if c["ps_ratio"] is not None else 50
value_score = round(v1 * 0.5 + v2 * 0.5, 1)
# Quality score (higher margins = better)
q1 = _normalize_score(c["net_margin_pct"], net_margins) if c["net_margin_pct"] is not None else 50
q2 = _normalize_score(c["operating_margin_pct"], op_margins) if c["operating_margin_pct"] is not None else 50
quality_score = round(q1 * 0.5 + q2 * 0.5, 1)
# Composite score weighted by style
if style == "growth":
composite = growth_score * 0.50 + quality_score * 0.30 + value_score * 0.20
elif style == "value":
composite = value_score * 0.50 + quality_score * 0.30 + growth_score * 0.20
elif style == "quality":
composite = quality_score * 0.50 + growth_score * 0.30 + value_score * 0.20
else:
composite = growth_score * 0.34 + quality_score * 0.33 + value_score * 0.33
c["scores"] = {
"growth": growth_score,
"value": value_score,
"quality": quality_score,
"composite": round(composite, 1),
}
# Sort by composite score descending
passed.sort(key=lambda c: c["scores"]["composite"], reverse=True)
# Add rank
for i, c in enumerate(passed):
c["rank"] = i + 1
return {
"style": style,
"filters_applied": {
"min_revenue_growth_pct": min_growth,
"min_net_margin_pct": min_margin,
"max_pe_ratio": max_pe,
},
"total_candidates": len(symbols),
"passed_filters": len(passed),
"ranked_companies": passed,
"filtered_out": filtered_out,
"errors": errors,
"scoring_weights": {
"growth": {"revenue_growth": 0.6, "ni_growth": 0.4},
"value": {"pe_ratio": 0.5, "ps_ratio": 0.5},
"quality": {"net_margin": 0.5, "operating_margin": 0.5},
"composite": (
"growth 50% + quality 30% + value 20%" if style == "growth"
else "value 50% + quality 30% + growth 20%" if style == "value"
else "quality 50% + growth 30% + value 20%" if style == "quality"
else "equal weight ~33% each"
),
},
}
def main():
parser = argparse.ArgumentParser(description="Stock screener with scoring (SEC EDGAR + Yahoo Finance)")
parser.add_argument("symbols", nargs="+", help="Stock symbols (e.g., AAPL MSFT GOOGL)")
parser.add_argument("--style", choices=["growth", "value", "quality"], default="growth",
help="Screening style (default: growth)")
parser.add_argument("--min-growth", type=float, default=None,
help="Minimum revenue growth YoY %% (e.g., 10)")
parser.add_argument("--min-margin", type=float, default=None,
help="Minimum net margin %% (e.g., 20)")
parser.add_argument("--max-pe", type=float, default=None,
help="Maximum P/E ratio (e.g., 35)")
args = parser.parse_args()
result = fetch_screen(
[s.upper() for s in args.symbols],
style=args.style,
min_growth=args.min_growth,
min_margin=args.min_margin,
max_pe=args.max_pe,
)
print(json.dumps(result, indent=2, default=str))
if __name__ == "__main__":
main()