Originally published on medium.com

Most stock analysis tools give you a number and expect you to trust it.
You type in a ticker, a black box does something, and out comes a âBuyâ or a âSell.â No explanation. No visibility into what drove the score. No way to check if the model just rewarded a company for one flashy metric while ignoring three warning signs.
Thatâs backwards. A useful analysis tool should make you a better analyst, not replace your judgment with someone elseâs opinion dressed up as math.
If youâre:
this is for you.
Hereâs what actually happens when most people try to analyze a stock before buying it.
They open the ticker on a broker app. They see the P/E ratio. They Google âis [ticker] a good stock.â They read three contradictory takes on Reddit. They close the laptop more confused than when they started.
The problem isnât a lack of data. Data is everywhere now, free APIs, broker apps, financial news sites.
The problem is that nobody structures it.
Growth without valuation context looks exciting. Valuation without quality context looks cheap for a reason. A single âBuyâ rating from an anonymous analyst tells you nothing about the underlying business.
Stock analysis doesnât need more information. It needs a framework that forces every piece of data to answer one specific question, and then combines those answers honestly.
Thatâs what weâre building here: a Python tool that pulls real financial data through EODHD, scores a company across five dimensions, and hands the interpretation (not the math) to Claude. By the end, youâll have working code you can point at any ticker.
Every investment decision, whether you realize it or not, comes down to five questions.
None of these questions has a single right answer. But each one has a defensible, calculable score, and thatâs the difference between an opinion and an analysis.
Iâve tested a handful of financial data providers for projects like this, and I keep coming back to EODHD for one reason: one API call gets you fundamentals, financial statements, earnings history, and analyst estimates for a single ticker instead of stitching together five different endpoints.
Through the fundamentals endpoint, a single request returns:
Hereâs the function everything else in this tool builds on:
import os
import requests
EODHD_API_KEY = os.environ.get("EODHD_API_KEY", "demo")
BASE_URL = "https://eodhd.com/api"
def get_fundamentals(ticker: str) -> dict:
"""Pull the full fundamentals payload for a single ticker."""
url = f"{BASE_URL}/fundamentals/{ticker}"
params = {"api_token": EODHD_API_KEY, "fmt": "json"}
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
return resp.json()
Everything downstream (quality, growth, health, valuation, momentum) reads from this same payload. No extra round trips.
Get the data behind this analyzer
EODHD gives you fundamentals, full financial statements, and analyst estimates through one API, with a free tier to test against.
â Start with EODHD
Three small helpers do the repetitive work across every scoring function below: pulling a field across several fiscal years, normalizing a raw number into a 0â100 sub-score, and checking whether a metric grew consistently.
def yearly_series(f, statement, field, n=6):
data = f["Financials"][statement]["yearly"]
dates = sorted(data.keys())[-n:]
return [(d, float(data[d][field])) for d in dates if data[d].get(field) is not None]
def latest_yearly(f, statement):
data = f["Financials"][statement]["yearly"]
return data[sorted(data.keys())[-1]]
def normalize(value, floor, ceiling):
"""Linear-scale a raw metric into a 0-100 sub-score."""
if value is None:
return None
score = (value - floor) / (ceiling - floor) * 100
return max(0, min(100, score))
def growth_consistency(values):
"""Share of consecutive periods that grew. 1.0 = grew every year."""
if len(values) < 2:
return None
ups = sum(1 for i in range(1, len(values)) if values[i] > values[i - 1])
return ups / (len(values) - 1)
Every scoring function from here on assumes these four are already imported.
A company can grow revenue every year and still be a bad business, if that growth never turns into real profit or cash.
Business quality answers a narrower question: when this company makes a sale, how much of it actually sticks?
We measure six things:
That last two matter more than people give them credit for. A company with one incredible year and four mediocre ones isnât high quality. It got lucky once.
def calculate_quality_score(f: dict) -> dict:
h = f["Highlights"]
revenue = yearly_series(f, "Income_Statement", "totalRevenue")
fcf = yearly_series(f, "Cash_Flow", "freeCashFlow")
net_income = yearly_series(f, "Income_Statement", "netIncome")
roe = h.get("ReturnOnEquityTTM") or 0
op_margin = h.get("OperatingMarginTTM") or 0
net_margin = h.get("ProfitMargin") or 0
fcf_margin = fcf[-1][1] / revenue[-1][1] if revenue and fcf else None
fcf_consistency = growth_consistency([v for _, v in fcf])
profit_consistency = sum(1 for _, v in net_income if v > 0) / len(net_income)
sub_scores = {
"roe": normalize(roe, 0.0, 0.40),
"operating_margin": normalize(op_margin, 0.0, 0.45),
"net_margin": normalize(net_margin, 0.0, 0.35),
"fcf_margin": normalize(fcf_margin, 0.0, 0.35),
"fcf_consistency": normalize(fcf_consistency, 0.0, 1.0),
"profit_consistency": normalize(profit_consistency, 0.0, 1.0),
}
weights = {"roe": .20, "operating_margin": .20, "net_margin": .15,
"fcf_margin": .15, "fcf_consistency": .15, "profit_consistency": .15}
final = sum(sub_scores[k] * weights[k] for k in sub_scores)
return {"score": round(final), "components": sub_scores}
normalize() maps a raw metric to a 0-100 sub-score using a floor and ceiling. A 40% ROE gets a full 100. A 0% or negative ROE gets 0. Everything between is linear. No metric can single-handedly push the score to 100. It gets weighted and averaged with five others, which is the entire point: one great number doesn't hide four mediocre ones.
Growth is where most âAI stock analysisâ tools quietly cheat. They see a big quarterly EPS number and treat it as proof of a thriving business.
Sometimes it is. Sometimes itâs a one-time gain from selling an asset, a tax credit, or marking an equity stake up in value, none of which tells you anything about whether the core business is expanding.
We look at five signals: quarterly revenue growth, 3-year revenue CAGR (which smooths out any single lumpy quarter), quarterly EPS growth, free cash flow growth, and revenue consistency across the last several fiscal years.
The EPS growth number gets one important adjustment: itâs capped.
def calculate_growth_score(growth: dict) -> dict:
# One-off items (asset sales, equity stakes, tax credits) can distort
# quarterly EPS by 100%+ without reflecting the underlying business.
# Capping prevents those spikes from dominating the score.
eps_yoy = growth["eps_growth_yoy"]
eps_yoy_capped = max(-0.5, min(eps_yoy, 0.60)) if eps_yoy is not None else None
sub_scores = {
"revenue_growth": normalize(growth["revenue_growth_yoy"], -0.05, 0.35),
"revenue_cagr_3y": normalize(growth["revenue_cagr_3y"], 0.0, 0.35),
"eps_growth": normalize(eps_yoy_capped, -0.20, 0.40),
"fcf_growth": normalize(growth["fcf_growth_yoy"], -0.20, 0.40),
"consistency": normalize(growth["revenue_consistency"], 0.0, 1.0),
}
weights = {"revenue_growth": .20, "revenue_cagr_3y": .25, "eps_growth": .20,
"fcf_growth": .20, "consistency": .15}
final = sum(sub_scores[k] * weights[k] for k in sub_scores)
return {"score": round(final), "components": sub_scores}
A stock that reports 300% EPS growth off a one-time gain shouldnât automatically outscore a company compounding revenue at a steady 20% a year. The cap keeps the score honest. Youâll see exactly why this matters later, when we compare two real companies.
Growth and quality tell you if a business is good. Financial health tells you if it can survive being wrong about something.
Every recession, rate hike, or bad product cycle eventually tests a companyâs balance sheet. The ones with too much debt and too little cash get forced into bad decisions, cutting R&D, diluting shareholders, selling assets at the worst possible time.
def calculate_financial_health_score(f: dict) -> dict:
bs = latest_yearly(f, "Balance_Sheet")
fcf = yearly_series(f, "Cash_Flow", "freeCashFlow")[-1][1]
equity = float(bs.get("totalStockholderEquity") or 0)
lt_debt = float(bs.get("longTermDebt") or 0)
current_assets = float(bs.get("totalCurrentAssets") or 0)
current_liab = float(bs.get("totalCurrentLiabilities") or 1)
cash = float(bs.get("cash") or 0) + float(bs.get("shortTermInvestments") or 0)
ebitda = f["Highlights"].get("EBITDA") or 0
debt_to_equity = lt_debt / equity if equity else None
current_ratio = current_assets / current_liab
net_debt_to_ebitda = (lt_debt - cash) / ebitda if ebitda else None
fcf_to_debt = fcf / lt_debt if lt_debt else 1.0
sub_scores = {
"debt_to_equity": 100 - normalize(debt_to_equity, 0.0, 1.0),
"current_ratio": normalize(current_ratio, 0.5, 3.0),
"net_debt_to_ebitda": 100 - normalize(net_debt_to_ebitda, -1.0, 3.0),
"fcf_to_debt": normalize(fcf_to_debt, 0.0, 2.0),
"cash_cushion": normalize(cash / current_liab, 0.0, 3.0),
}
weights = {"debt_to_equity": .25, "current_ratio": .20, "net_debt_to_ebitda": .20,
"fcf_to_debt": .20, "cash_cushion": .15}
final = sum(sub_scores[k] * weights[k] for k in sub_scores)
return {"score": round(final), "components": sub_scores}
Two things worth explaining: debt-to-equity and net-debt-to-EBITDA are inverted (100 - normalize(...)) because for these metrics, lower is better. A company with negative net debt (more cash than debt) scores near the top. A highly leveraged company scores near the bottom, regardless of how fast it's growing.
This is the step most stock analysis Python tutorials skip entirely, and itâs the one that matters most.
A 91/100 quality score means nothing if youâre paying a 60x P/E for it. Valuation isnât a judgment on the company. Itâs a judgment on the price youâd pay today, relative to what the business is actually delivering.
We look at forward P/E, PEG (P/E adjusted for expected growth), EV/EBITDA (capital-structure-neutral, useful for comparing companies with different debt levels), Price-to-Free-Cash-Flow, and Price-to-Sales.
def calculate_valuation_score(f: dict) -> dict:
v, h = f["Valuation"], f["Highlights"]
fcf = yearly_series(f, "Cash_Flow", "freeCashFlow")[-1][1]
price_fcf = h["MarketCapitalization"] / fcf
# Lower is cheaper for every one of these metrics, so the normalized
# score gets inverted: a lower P/E produces a HIGHER valuation score.
sub_scores = {
"forward_pe": 100 - normalize(v.get("ForwardPE"), 10, 40),
"peg": 100 - normalize(h.get("PEGRatio"), 0.5, 2.5),
"ev_ebitda": 100 - normalize(v.get("EnterpriseValueEbitda"), 8, 25),
"price_fcf": 100 - normalize(price_fcf, 15, 45),
"price_sales": 100 - normalize(v.get("PriceSalesTTM"), 2, 12),
}
weights = {"forward_pe": .20, "peg": .25, "ev_ebitda": .20,
"price_fcf": .20, "price_sales": .15}
final = sum(sub_scores[k] * weights[k] for k in sub_scores)
return {"score": round(final), "components": sub_scores}
Hereâs the rule that governs this entire section: a high-quality company should never automatically receive a high valuation score. Theyâre answering different questions. One asks âis this a good business.â The other asks âam I paying a fair price for it today.â A great company and a great investment are not the same claim.
The momentum score isnât a trading signal. Itâs not trying to predict next weekâs price.
Itâs asking something simpler: right now, is this companyâs trajectory pointing up, flat, or down?
We combine five signals: where the 50-day moving average sits within the 52-week range, whether the 50-day average is above or below the 200-day average (a basic trend filter), the average earnings surprise over the last four reported quarters, quarterly revenue growth, and the share of analysts rating the stock Buy or Strong Buy.
def calculate_momentum_score(f: dict) -> dict:
t, h = f["Technicals"], f["Highlights"]
reported = sorted(
[(d, v) for d, v in f["Earnings"]["History"].items()
if v.get("surprisePercent") is not None],
reverse=True,
)[:4]
avg_surprise = sum(v["surprisePercent"] for _, v in reported) / len(reported)
ratings = f["AnalystRatings"]
total = sum(ratings.get(k, 0) for k in
["StrongBuy", "Buy", "Hold", "Sell", "StrongSell"])
analyst_skew = (ratings.get("StrongBuy", 0) + ratings.get("Buy", 0)) / total
price_position = (t["50DayMA"] - t["52WeekLow"]) / (t["52WeekHigh"] - t["52WeekLow"])
trend = 70 if t["50DayMA"] > t["200DayMA"] else 30
sub_scores = {
"price_position": normalize(price_position, 0.0, 1.0),
"trend": trend,
"earnings_surprise": normalize(avg_surprise, -15, 15),
"revenue_growth": normalize(h.get("QuarterlyRevenueGrowthYOY"), -0.05, 0.30),
"analyst_skew": normalize(analyst_skew, 0.4, 1.0),
}
weights = {"price_position": .20, "trend": .20, "earnings_surprise": .25,
"revenue_growth": .20, "analyst_skew": .15}
final = sum(sub_scores[k] * weights[k] for k in sub_scores)
label = "Improving" if final >= 65 else "Stable" if final >= 45 else "Deteriorating"
return {"score": round(final), "label": label, "components": sub_scores}
Keep the earnings-surprise caveat in mind. Itâs the same problem from Step 2, showing up again: a huge surprise percentage can come from a one-off gain instead of real operating strength. Weâll see exactly that in the comparison below.
Each of the five scores gets its own weight in the final number:
| Category | Weight | Why |
|---|---|---|
| Business quality | 25% | The foundation. Everything else is built on a real, profitable business. |
| Growth | 20% | Confirms the business is expanding, not just efficient. |
| Financial health | 20% | Determines whether the company survives a bad year. |
| Valuation | 20% | Determines whether todayâs price makes sense. |
| Momentum | 15% | The most recent, and least reliable, signal. Weighted lowest on purpose. |
Missing data doesnât break the calculation. Every sub-score function only averages the components that actually came back from the API. A missing PEG ratio (common for unprofitable companies) just gets excluded from the valuation average instead of silently defaulting to zero or crashing the script.

This is the part where a lot of âAI stock analyzerâ projects go wrong. They ask an LLM to output a score directly, which means the score is whatever the model felt like generating that day, not something you can audit or reproduce.
Here, Claude never sees raw financial statements and never invents a number. It receives the finished scores as structured JSON and writes the plain-language explanation around them.
import anthropic
client = anthropic.Anthropic()
def interpret_report(report: dict) -> str:
prompt = f"""
You are a financial analyst explaining a stock scoring report to a retail investor.
Do not change or second-guess the scores below. Use them as ground truth.
{report}
Write four short sections:
1. What the company does well
2. Where it's weak
3. The single biggest risk right now
4. What would need to change for the thesis to strengthen or weaken
Do not recommend buying or selling. Describe trade-offs, not conclusions.
"""
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=800,
messages=[{"role": "user", "content": prompt}],
)
return response.content[0].text
The division of labor matters here. Python does the math because math should be reproducible: run it twice, get the same score. Claude does the writing because language is where an LLM actually adds value, translating five numbers and a dozen ratios into something a human can act on.
Letâs run this against a real ticker. Hereâs what the analyzer returns for META, pulled live from EODHD:
META: Investment Analysis
Business Quality 77/100
Growth 47/100
Financial Health 69/100
Valuation 63/100
Momentum 57/100 (Stable)
Overall Score 64/100
Walking through what actually drove each number:
Quality (77/100). METAâs ROE sits at 29.85%, operating margin at 34.83%, and itâs been profitable every year in the dataset. This is a genuinely well-run business. The one thing holding the score back from higher is FCF consistency, free cash flow dipped this year, which brings us to growth.
Growth (47/100). Revenue is up 28% year over year, which on its own would score well. But quarterly EPS fell 13.4% and free cash flow dropped nearly 15% from the prior year. The reason is visible right in the cash flow statement: capital expenditures nearly doubled, from $37.3B to $69.7B, as the company pours money into AI infrastructure. Thatâs not necessarily bad. Itâs a bet on future capacity. But it means less cash left over today, and the growth score reflects that honestly instead of only looking at the flattering revenue line.
Financial health (69/100). Debt-to-equity is a conservative 27%, the current ratio is a healthy 2.6x, and the company holds more cash than debt on a net basis. This is a business that can absorb a rough year without financial stress.
Valuation (63/100). A forward P/E of 19.9 and a PEG of 0.89 (below 1.0 generally signals the stock is priced reasonably relative to its growth) are the standout numbers here. Price-to-FCF at roughly 32.6x is the one metric pulling the score down, a direct consequence of that same FCF dip from the capex surge.
Momentum (57/100, Stable). The stock is trading in the lower half of its 52-week range, and the 50-day average sits just below the 200-day average, a mild downtrend. Analyst sentiment stays strongly positive (80% Buy or Strong Buy), and earnings surprises over the last four quarters average a modest 2.6%, mixed between one miss and three beats.
Reading the dashboard together: META remains a high-quality, financially conservative business trading at a reasonable price. The growth dip isnât a red flag on its own, itâs the visible cost of an infrastructure bet the market hasnât fully priced in either direction yet. Thatâs exactly the kind of nuance a single âBuy/Sellâ signal would erase.
Running the same analyzer against Alphabet (GOOGL) makes the trade-offs concrete.
META GOOGL
Quality 77 85
Growth 47 65
Financial Health 69 73
Valuation 63 52
Momentum 57 81
Overall 64 71
Alphabet leads on four of five categories. ROE of 48.7%, revenue thatâs grown every year in the dataset without a single down year, and a momentum score boosted by the stock trading near its 52-week high.
But look at valuation: META scores higher (63 vs. 52). Alphabet trades at a Price-to-FCF near 57.7x, close to double METAâs 32.6x, and a Price-to-Sales of 9.5 against METAâs 6.6. Alphabet is the stronger business on paper right now. Itâs also priced like one.
Thereâs a second thing worth catching here, because itâs exactly the trap Step 2 and Step 5 were built to avoid. Alphabetâs quarterly EPS growth shows up at roughly 294% year over year, and its average earnings surprise over the last four quarters comes in at 87%. Both numbers are almost entirely inflated by one-off gains, not a sudden tripling of the core ad and cloud business. The growth score caps that EPS spike before it can dominate the number. The momentum score doesnât apply the same cap to earnings surprise, and thatâs worth knowing if youâre reading the 81 at face value: some of it is real operating strength, some of it is accounting noise from marked-up investments.
Neither company gets a âwinnerâ label here. Alphabet offers more business strength for a higher price. META offers a smaller margin of safety on quality in exchange for a more reasonable valuation. That trade-off, not a single blended number, is the actual investment decision.
Worth being direct about what this tool doesnât do.
It has no view on competitive moats, management quality, or regulatory risk, things that donât show up cleanly in a financial statement. Itâs backward-looking by nature; every ratio here comes from the last reported quarter or year, not from what happens next. Earnings surprises and EPS growth can get distorted by one-time items, as the Alphabet example just showed. And it says nothing about your specific situation: your time horizon, your existing portfolio concentration, your risk tolerance.
This is a starting point for research, not a substitute for it.
Financial data shouldnât make the investment decision for you. It should make you better informed when you make it yourself.
A quality score of 91 and a valuation score of 40 arenât contradictory. Theyâre two honest answers to two different questions. Learning to hold both in your head at once, instead of collapsing them into a single âBuyâ or âSell,â is most of what separates a systematic investor from someone reacting to whatever headline they read that morning.
Is EODHD free to use for this kind of analysis?
EODHD offers a free tier with limited API calls, enough to test the fundamentals endpoint and build a prototype. Production use across many tickers requires a paid plan.
Can I run this on any stock, not just US tickers?
Yes. EODHD covers global exchanges, so the ticker format just needs the right suffix (.US, .LSE, .PA, and so on). The scoring logic itself doesn't change.
Why use Claude instead of just printing the raw scores?
The scores tell you what happened. An LLM is useful for explaining why it might matter and what to watch next, in plain language, without changing the underlying numbers.
How is this different from a stock screener?
A screener filters thousands of stocks by simple thresholds. This tool goes deep on one ticker at a time, explaining the reasoning behind each score instead of just ranking companies against each other.
Build your own version of this analyzer
Every score in this article came from one EODHD API call per ticker: fundamentals, financial statements, earnings history, and analyst estimates in a single response. Youâll get:
Building something with financial data?
If youâre an API or fintech company looking to explain your product through practical, code-first content instead of marketing fluff, I write pieces exactly like this one.
â See more of my work
Looking for technical content for your company? I can help â LinkedIn · kevinmenesesgonzalez@gmail.com