Python ·

Turning Warren Buffett's Principles Into a Trading Bot

Who Warren Buffett is, the investing principles he is known for, and how MoatHunter turns them into a screener, a DCF model and a paper trading framework.


MoatHunter is a small Python bot that scans around 3,300 stocks across 27 indices twice every weekday, looking for one thing: a great business that is temporarily cheap. When it finds one, it values the company with a discounted cash flow model, buys on a paper trading account, sets a stop-loss and tells me about it in Slack.

Almost none of the ideas in it are mine. They belong to Warren Buffett, and most of the work was figuring out how to turn a philosophy that is famously about judgement into rules a computer can apply to thousands of companies without getting bored.

This is not a tutorial. It is a look at the ideas, how each of them turned into a concrete rule, and some of the code that does it. Every threshold mentioned below is a single configurable number, so if you build something similar, feel free to disagree with mine.

Who is Warren Buffett?

Warren Buffett was born in Omaha, Nebraska in 1930 and is generally considered the most successful investor of the last century. He bought his first stock at eleven, studied under Benjamin Graham at Columbia, and ran investment partnerships before taking control of a struggling textile company called Berkshire Hathaway in 1965. Over the next sixty years he turned it into a conglomerate that owns businesses like GEICO, BNSF Railway and See's Candies outright, and holds large stakes in companies like Apple, Coca-Cola and American Express. He handed the CEO role to Greg Abel at the start of 2026.

What makes him interesting to a programmer is not the returns, it is that he has spent decades explaining exactly how he thinks. The annual Berkshire shareholder letters are free to read, written in plain English, and remarkably consistent. That consistency is what makes the attempt at automating it possible at all.

Buffett's approach started as Benjamin Graham's: buy things for less than they are worth. Over time, largely through his partner Charlie Munger, it shifted toward quality. The often quoted line is that it is far better to buy a wonderful company at a fair price than a fair company at a wonderful price. Both halves of that sentence end up in the bot: a long list of rules for "wonderful company", and a valuation model for "fair price".

The principles, and the rules they became

Every one of Buffett's principles is qualitative. He can read an annual report and decide a brand is durable; a script reading financial data cannot. So the approach is to take each principle, ask what would show up in the numbers of a business that has it, and turn that into a threshold. The translation always loses something, and I will try to be honest about where.

Two rules apply to everything that follows. A stock has to pass all of the criteria, not most of them. And missing data counts as a fail. If the bot cannot prove a company is good, it assumes it is not:

def _above(name, actual, threshold, note=""):
    passed = actual is not None and actual >= threshold
    if actual is None:
        note = note or "data unavailable"
    return CriterionResult(name=name, passed=passed, actual=actual,
                           threshold=threshold, direction="above", note=note)

Every check returns one of these results with the actual value, the threshold and a note, so a rejected stock always comes with a reason.

Buy a business, not a ticker

A share is a fractional ownership of a real company. Its value over the long run is whatever cash that company will produce for its owners, not what the chart did last week. If you would not be comfortable owning the whole business, you should not own a piece of it.

This is the easiest principle to implement, because it is mostly about what the bot does not do. There are no moving averages, no momentum signals and no chart patterns anywhere in it. The share price is used in exactly two places: to calculate valuation ratios, and to compare against the estimated intrinsic value. Everything else comes from the income statement, the balance sheet and the cash flow statement.

Rule number one: don't lose money

Buffett's most famous rule is never lose money, and rule number two is never forget rule number one. He does not mean that every investment has to go up. He means avoiding the kind of mistake you cannot recover from: a company that goes bankrupt, is slowly shrinking, or needs ever more money from shareholders to stay alive. A great business with too much debt can still be wiped out by a single bad year.

These became the hard kills. They run before anything else, and a stock that trips one is rejected immediately, no matter how cheap it looks:

def _hard_kill(self, snap):
    cfg = self.cfg

    if snap.debt_to_ebitda is not None and snap.debt_to_ebitda > cfg.hard_max_debt_to_ebitda:
        return f"HARD KILL: Debt/EBITDA {snap.debt_to_ebitda:.1f}x > {cfg.hard_max_debt_to_ebitda}x"

    if snap.roic is not None and snap.roic < cfg.hard_min_roic:
        return f"HARD KILL: ROIC {snap.roic:.1%} < {cfg.hard_min_roic:.0%} minimum"

    if snap.negative_fcf_years is not None and snap.negative_fcf_years > cfg.hard_max_negative_fcf_years:
        return f"HARD KILL: {snap.negative_fcf_years} years of negative FCF"

    if cfg.hard_no_overall_revenue_decline and snap.revenue_declined_overall is True:
        return "HARD KILL: Revenue lower today than at start of available history"

    return None
  • Debt over 5× EBITDA. EBITDA is roughly the cash profit before interest and tax. A company that owes more than five years of it has very little room for a bad year.
  • ROIC below 8%. If a company earns less on its capital than that capital costs, every dollar it reinvests destroys value. Growth makes it worse, not better.
  • Three or more years of negative free cash flow. One bad year can be an investment cycle. Three is a business that consumes money instead of producing it.
  • Revenue lower than at the start of the history. A shrinking top line usually means the moat is already eroding.

Notice that the hard kills only fire when there is data. Missing data is handled later by the soft criteria, which fail on it. The hard kills are reserved for evidence of real danger.

Look for a moat

A great business has something that protects its profits from competitors, like a brand, a network effect, switching costs or a cost advantage. Buffett calls this an economic moat, after the moat around a castle. You cannot see a moat in a spreadsheet, but you can see its footprints. Without a moat, competitors pile in whenever returns are high, and those returns get competed down toward average. So a company that has earned unusually high returns for years is probably protected by something.

Three numbers try to capture that:

  • ROIC of at least 10%. Return on invested capital is the after-tax operating profit divided by all the capital the business uses, both equity and debt. It is the closest single number to "how good is this business at turning money into more money".
  • ROE of at least 15%. Return on equity is profit divided by shareholders' equity. It is the number Buffett himself has historically pointed to, but it can be inflated by borrowing, which is why it never stands alone and is always paired with the leverage rules further down.
  • Gross margin of at least 25%. What is left of each sale after the direct cost of making the product. A high gross margin is a sign of pricing power, which is what a strong brand gives you. People pay more for a Coca-Cola than for a supermarket cola that costs the same to make.

ROIC is calculated from the raw statements rather than taken from the data provider:

def _compute_roic(ebit, tax_rate, balance):
    """ROIC = NOPAT / Invested Capital"""
    if ebit is None:
        return None
    effective_tax = tax_rate if (tax_rate and 0 < tax_rate < 1) else 0.21
    nopat = ebit * (1 - effective_tax)

    ic = _scalar(balance, ["Invested Capital"])
    if ic is None or ic <= 0:
        return None
    return nopat / ic

Here is the first place where the translation loses something. High returns tell you there probably was a moat. They say nothing about whether it will still be there in ten years, which is the question Buffett actually cares about.

Predictability

Buffett prefers boring businesses whose earnings he can be fairly confident will be higher in five, ten and twenty years. Not because boring is a virtue, but because you can only value something you can predict. A company whose profits swing wildly cannot be valued with any confidence, so any margin of safety you think you have is imaginary.

  • Positive earnings in at least 80% of the years in the available history.
  • Revenue growing at least 5% a year, measured as a compound annual growth rate from the first year to the last.
  • No single year where revenue dropped more than 20%. A business that loses a fifth of its sales in a year is either highly cyclical or was disrupted. Either way it is not predictable.
rev_series = _row_series(income, ["Total Revenue"])
if rev_series is not None and len(rev_series) >= 2:
    # income_stmt columns are newest-first; sort oldest-first for CAGR
    rev_sorted = rev_series.sort_index()
    first, last = float(rev_sorted.iloc[0]), float(rev_sorted.iloc[-1])
    n = len(rev_sorted) - 1
    if first > 0:
        result["revenue_cagr"] = (last / first) ** (1 / n) - 1
        result["revenue_history_years"] = n + 1
        result["revenue_declined"] = last < first

    # worst single-year drop
    rev_newest_first = rev_series.sort_index(ascending=False)
    pct_changes = rev_newest_first.pct_change()
    drops = pct_changes[pct_changes < 0]
    result["max_revenue_drop"] = float(abs(drops.min())) if not drops.empty else 0.0

The weakness here is history. The free data only goes back about four years for most companies, while Buffett would want ten or more. Four good years can easily be one good cycle.

Owner earnings, not accounting earnings

Reported net income can be shaped by accounting choices like depreciation schedules, one-off items and how revenue is recognised. What an owner can actually take out of a business is the cash left after the spending needed to keep it competitive. Buffett calls this owner earnings, and it is close to what we now call free cash flow. A company whose profits never turn into cash is a warning sign.

  • Free cash flow margin of at least 10%. At least a tenth of every sale should end up as cash.
  • Free cash flow conversion of at least 75%. Free cash flow divided by net income. If a company reports $100 of profit but only $30 shows up as cash, something in the accounting deserves a closer look.

Closely related is how much capital the business needs. Buffett often uses See's Candies as the example: Berkshire bought it in 1972, and for decades it sent almost all its profits back to Omaha, because a candy shop does not need much new investment to grow. That money could then be used to buy other businesses. Two rules try to find more See's:

  • CapEx under 8% of sales. Capital expenditure is what the company has to spend on factories, machines and equipment just to keep going. Less is better.
  • Asset turnover above 0.5. Revenue divided by total assets. It filters out companies that need an enormous asset base to produce modest sales.

A conservative balance sheet

This is rule number one again, but softer. The hard kills throw out the dangerous cases; these rules ask for a company that could survive a recession without anyone else's help:

  • Debt under 3× EBITDA, ideally under 2×.
  • Interest coverage above 5×. Operating profit divided by interest expense. A company earning five times its interest bill can take a big hit before it struggles to pay its lenders.
  • Net debt under 5 years of free cash flow. How long it would take to pay off all debt, minus cash, if every bit of free cash flow went to it.

One nice detail is how companies with no debt at all are handled. They have no interest expense, so interest coverage cannot be calculated, and under the "missing data is a fail" rule they would be rejected. That is precisely the wrong answer for the kind of company Buffett loves, so they get a special case:

interest_coverage = None
if ebit is not None and interest_exp is not None and interest_exp != 0:
    interest_coverage = ebit / abs(interest_exp)
elif total_debt <= 0:
    # Debt-free (or no debt reported) - no interest burden to cover,
    # so treat coverage as effectively unlimited rather than "missing".
    interest_coverage = 999.0

Management you can trust

Buffett wants managers who think like owners: people who have a meaningful amount of their own money in the business, who allocate capital sensibly and who do not quietly dilute existing shareholders by issuing new shares. Integrity is the part he talks about most, and it is the hardest thing to measure. The bot settles for two proxies:

  • Insider ownership of at least 5%. Executives and directors who own a lot of the company feel losses the same way shareholders do.
  • Share dilution under 1% a year. If the share count keeps growing, your slice of the business keeps shrinking, even if the business does well. A negative number means the company is buying back shares, which passes easily.
def _compute_share_dilution(balance, income):
    """
    CAGR of shares outstanding over available history.
    Positive = dilution, negative = buybacks.
    """
    shares = _row_series(balance, ["Ordinary Shares Number", "Share Issued"])
    if shares is None or len(shares) < 2:
        shares = _row_series(income, ["Diluted Average Shares", "Basic Average Shares"])
    if shares is None or len(shares) < 2:
        return None

    sorted_shares = shares.sort_index()
    oldest, newest = float(sorted_shares.iloc[0]), float(sorted_shares.iloc[-1])
    n = len(sorted_shares) - 1
    if oldest <= 0:
        return None
    return (newest / oldest) ** (1 / n) - 1

Insider buying over the last twelve months is also calculated and shown, but it is only a hard requirement if I switch it on. Insiders sell for lots of reasons, like taxes, a house or diversification, but they generally only buy for one.

A fair price

The quality rules find wonderful companies. The second half of the sentence is the fair price. Before running a full valuation, three quick multiples filter out anything that is obviously expensive:

  • P/E under 20. Price divided by earnings. Under 20 is fair for a quality business, under 15 is attractive.
  • EV/EBIT under 15. Like P/E, but uses enterprise value, so it accounts for debt and cash. Two companies with the same P/E can look very different once you include what they owe.
  • PEG under 1. P/E divided by the growth rate. A P/E of 20 is cheap for a company growing 25% a year and expensive for one growing 5%. This one is often missing in the data, and it is the one exception to the missing-data rule: if there is no PEG, the check is skipped instead of failed.

Intrinsic value and a margin of safety

Intrinsic value is the discounted value of all the cash a business will produce from now on. Nobody knows that number exactly, so Graham's answer was to only buy when the price is well below your estimate. The gap is the margin of safety, and it is what protects you when your estimate is wrong. It will be wrong.

In the bot this is the final gate: the price has to be at least 10% below the estimated intrinsic value. How that value is estimated gets its own section below.

Mr. Market

Graham described the market as a moody business partner who offers to buy or sell you his share every day at a different price. Some days he is euphoric, some days he is depressed. You are free to ignore him, and you should only trade with him when his price is foolish. Be fearful when others are greedy, and greedy when others are fearful.

A bot is quite good at this one, because it has no feelings. It does not read headlines, it does not care that a stock fell 30% last month, and it does not get excited about a sector everyone is talking about. It checks the price against its estimate twice a day and does nothing unless the gap is large enough. Doing nothing is the most common outcome by far.

Patience

Once Buffett owns a great business, his favourite holding period is famously forever. He sells when the business itself has changed for the worse, or occasionally when the price has become absurd, but not because the share price wobbled. This is the principle the bot follows least well, and I will come back to why in the section on stop-losses.

The rules at a glance

Principle Rules
Don't lose money (hard kills) Debt / EBITDA ≤ 5×, ROIC ≥ 8%, fewer than 3 years of negative FCF, revenue not shrinking
Economic moat ROIC ≥ 10%, ROE ≥ 15%, gross margin ≥ 25%
Predictability Positive earnings in ≥ 80% of years, revenue CAGR ≥ 5%, no single-year revenue drop over 20%
Owner earnings FCF margin ≥ 10%, FCF / net income ≥ 75%, CapEx / sales < 8%, asset turnover > 0.5
Conservative balance sheet Debt / EBITDA < 3×, interest coverage > 5×, net debt / FCF < 5 years
Trustworthy management Insider ownership ≥ 5%, share dilution < 1% a year
Fair price P/E < 20, EV / EBIT < 15, PEG < 1
Margin of safety Price at least 10% below DCF intrinsic value

What does not translate

A few things are deliberately left out or worked around. Real estate is excluded, because property companies are valued on assets rather than cash flow and their numbers make the screener misbehave. Anything under a $500M market cap is skipped, mostly because the free data gets unreliable down there. And there is no attempt at "circle of competence", Buffett's rule of only investing in what you understand. A screener has no circle; it treats a bank and a software company the same way. That is exactly where a human still has to look at the result.

Estimating intrinsic value

Stocks that survive the screener get valued with a two-stage discounted cash flow model:

  1. Start from the company's current free cash flow.
  2. Pick a growth rate: the historical EPS growth if it exists, otherwise revenue growth, otherwise a 7% default. Cap it at 25%, because nothing compounds that fast for a decade.
  3. Project ten years of cash flow. For the first five years use that growth rate, then glide it down toward the terminal rate.
  4. Add a terminal value using the Gordon growth model, assuming 2.5% growth forever, roughly in line with the economy.
  5. Discount everything back at 10%, the hurdle rate Buffett is often said to use, subtract net debt and divide by shares outstanding.
for year in range(1, self.dcf.projection_years + 1):
    current_fcf *= (1 + growth)
    # Glide growth toward terminal rate in later years
    if year > 5:
        blend = (year - 5) / (self.dcf.projection_years - 5)
        growth = growth * (1 - blend) + g_terminal * blend
    pv = current_fcf / (1 + r) ** year
    projected_pv += pv

terminal_fcf = current_fcf * (1 + g_terminal)
terminal_value = terminal_fcf / (r - g_terminal)
terminal_pv = terminal_value / (1 + r) ** self.dcf.projection_years

enterprise_value = projected_pv + terminal_pv
net_debt = (snap.total_debt or 0) - (snap.cash_and_equivalents or 0)
equity_value = enterprise_value - net_debt
iv_per_share = equity_value / snap.shares_outstanding

The margin of safety is then simply (intrinsic value - price) / intrinsic value. If it clears the threshold, the stock is a candidate, and candidates are ranked by how big that margin is.

The growth rate is the input that matters most, and there was a subtle bug here early on. The data provider has an "earnings growth" field, but it is a single trailing year, and it can differ wildly from the multi-year trend, sometimes even in sign. Feeding a one-year blip into a ten-year projection produced some spectacular intrinsic values. It now calculates the compound growth of earnings per share from the statements first, and only falls back to the provider's number if that is not possible.

A DCF is extremely sensitive to its inputs. Nudge the growth rate or the discount rate by a couple of percent and the answer moves a lot. That is not a bug in the model, it is the reason Graham insisted on a margin of safety in the first place. The point is not to get the number right, it is to only act when the price is low even if the number is quite wrong.

From screener to trading framework

A screener tells you what to look at. A trading framework also has to decide how much to buy, when to get out and how to keep a list of positions honest over time. The full pipeline looks like this:

Universe (~3,300 tickers from 27 indices)
    |
Hard kills          debt, ROIC, negative FCF, shrinking revenue
    |
Value screener      19 fundamental criteria
    |
DCF valuation       two-stage, 10% discount rate
    |
Margin of safety    rank candidates by discount to intrinsic value
    |
Alpaca order        market buy + stop-loss, paper account
    |
Slack               scan summary, positions opened/closed, stops hit

Building the universe

Before it can look for anything, the bot needs a list of what to look at. Index constituents for the S&P 500, FTSE 100, DAX, Nikkei 225, OMX Copenhagen 25 and the rest are scraped from Wikipedia and cached for a day. The fiddly part is that every exchange names its stocks differently. Novo Nordisk is NOVO-B.CO, Adidas is ADS.DE, Toyota is 7203.T, so each index has its own small parser that knows which suffix to add. The STOXX Europe 600 table, for example, only has a bare ticker and a country, and share classes are written with spaces or dots that the data provider does not accept:

for _, row in df.iterrows():
    ticker = str(row["Ticker"]).strip().replace(" ", "-").replace(".", "-")
    country = str(row["Country"]).strip()
    if not ticker or ticker == "nan" or country in skip_countries:
        continue
    suffix = _STOXX600_COUNTRY_SUFFIX.get(country, "")
    tickers.append(f"{ticker}{suffix}" if suffix else ticker)

Overlapping indices are deduplicated, and a ticker that cannot be fetched is put on a 30 day timeout instead of failing again on every run, since it is usually a delisting or a stale Wikipedia table.

Fetching fundamentals

For each ticker the bot pulls the income statement, balance sheet, cash flow statement and a handful of market data points, and condenses them into a single snapshot. Some ratios come straight from the data provider. Others, like ROIC, revenue CAGR, the number of years with negative free cash flow or how much the share count has grown, are calculated from the raw statements, as in the examples above. Every snapshot also records how many years of history the calculation was based on, because "positive earnings in 80% of years" means something very different over four years than over ten.

International data has its own traps. London-listed stocks are quoted in pence, not pounds, so a £50 share shows up as 5,000. Without correcting for that, every UK stock looks a hundred times more expensive than it is:

_MINOR_UNIT_CURRENCIES = {
    "GBp": 100,   # London Stock Exchange - pence vs GBP
    "GBX": 100,   # alternate code for the same thing
    "ZAc": 100,   # Johannesburg Stock Exchange - cents vs ZAR
}

minor_unit_divisor = _MINOR_UNIT_CURRENCIES.get(info.get("currency"))
if minor_unit_divisor:
    price = price / minor_unit_divisor

Screening, and remembering why

The screener runs in a fixed order, cheapest first: the hard kills, then the sector and size filters, then the 19 soft criteria. Because every criterion records its actual value and threshold, the output for a single stock reads like a report card (the numbers here are made up):

✅ ROIC: 18.40% (above 10.00%)
✅ ROE: 24.10% (above 15.00%)
✅ Gross Margin: 61.20% (above 25.00%)
✅ Earnings Stability: 100.00% (above 80.00%)  [4/4 profitable years]
❌ Revenue CAGR: 3.10% (above 5.00%)
...
❌ P/E Ratio: 27.40 (below 20.00)

That turned out to matter more than the verdict itself. "Fails" is useless; "great business, growing a bit slowly and too expensive right now" is something you can act on.

Near misses

A stock that fails is not necessarily forgotten. Stocks that passed most of the quality criteria are kept as near misses and ranked by how close they came. Stocks that passed every quality check but were simply too expensive rank above all of those, scored by how close the price is to the required margin of safety:

# Failed the screener: score is the share of criteria passed (0.0 - 1.0)
proximity = passed_count / total_count

# Passed every quality check but too expensive: always ranks above 1.0
proximity = 1.0 + (actual_mos / required_mos)

In practice this is the most interesting list: great businesses that are not quite cheap yet, which is exactly the kind of watchlist Buffett talks about keeping until Mr. Market has a bad day.

Placing trades

The bot then checks how many free slots the portfolio has and works down the candidate list, biggest margin of safety first. It skips anything it already owns, and it only buys when that stock's own exchange is open. It runs twice a day, just after the European open and just after the US open, and a Japanese stock found during the European run simply waits. The exchange is worked out from the ticker suffix (slightly simplified):

suffix = ticker.split(".")[-1] if "." in ticker else ""
tz_name, open_time, close_time = _EXCHANGE_HOURS[suffix]   # e.g. "CO" -> Copenhagen, 09:00-17:00
local_now = datetime.now(ZoneInfo("UTC")).astimezone(ZoneInfo(tz_name))

if local_now.weekday() >= 5:
    return False
return open_time <= local_now.time() < close_time

Position sizing is intentionally boring: at most 10 open positions, and each one gets the smallest of three limits.

position_usd = min(
    cfg_p.max_position_size_usd,                       # $5,000
    account.portfolio_value * cfg_p.max_portfolio_pct, # 10% of the portfolio
    available_cash * 0.95,                             # leave a small cash buffer
)

Buffett himself runs a very concentrated portfolio, but he also has decades of conviction and the ability to read every annual report. A bot that can be fooled by a bad data point should spread its bets.

Orders go to a paper trading account at Alpaca as a market buy, and every open position is recorded in a small local ledger with its entry price, the margin of safety at the time of purchase and its stop-loss level.

Telling me about it

Every scan ends with a Slack message: the new candidates, the best near misses, which positions were opened or closed, and any tickers that dropped out of the data. Stocks I already own are left out, so the message only contains things I could actually act on, and if there is nothing to say, it stays quiet. A dry-run mode does the whole scan without placing a single order, which is how almost every change was tested.

The stop-loss

Each position gets a 12% stop-loss, placed as an order with the broker and also checked by the bot itself as a backup in case the order never fired. This is the part Buffett would hate. He does not use stop-losses; if a great business gets cheaper, he buys more. But his conviction comes from understanding the business. Mine comes from a spreadsheet of ratios that might be stale or simply wrong, so a price falling hard is a useful signal that the screener missed something. The stop is the honest admission that the bot is not Buffett.

Reviewing what I own

For positions I hold myself, there is a review mode that runs each holding through the same screener and DCF and returns KEEP, WATCH or SELL. This is the closest the bot gets to Buffett's selling rules: sell when the business has deteriorated or the price has become silly, not because the share price moved.

# Hard kill: the business itself has become dangerous
if screen.skip_reason and "HARD KILL" in screen.skip_reason:
    return "SELL"

# Price has run well past what the business is worth
if overvalued_by is not None and overvalued_by > 0.10:
    return "SELL"

# Quality has clearly deteriorated
if fail_rate > 0.50:
    return "SELL"

# Early signs of trouble, or slightly expensive
if fail_rate > 0.30 or (overvalued_by is not None and overvalued_by > 0):
    return "WATCH"

return "KEEP"

As long as a holding still looks like a great business at a sensible price, it stays. That is as close to "forever" as I could get it.

The whole thing runs from cron on a cheap VPS. It also has a couple of Claude Code skills on top, like /analyse, which runs every criterion for a single ticker, ignoring the hard kills, and then argues the bull and the bear case. That turned out to be the most useful part day to day, because it shows why a stock fails instead of just that it does.

What I learned

The strict version finds nothing

The first version required a 25% margin of safety, a P/E under 18 and an EV/EBIT under 10. Across 3,300 stocks it found almost nothing, in a market where quality companies rarely trade at deep discounts. What followed was a slow retreat: margin of safety to 20%, then 10%, P/E to 20, EV/EBIT to 15, minimum market cap from $1B down to $500M. Each change was reasonable on its own, and every one of them moved the bot a little further from Buffett and a little closer to "buy whatever is least expensive right now". If you build something like this, decide up front whether you are willing to sit in cash for months. Buffett is. A bot that is expected to do something will slowly be tuned until it does.

Free data is the weakest link

Everything comes from yfinance, which is free and good enough to experiment with, but it only gives you around four years of history for most companies. Buffett looks at ten. Tickers on non-US exchanges need the right suffix, some index constituents on Wikipedia are out of date, prices come in the wrong currency unit, and a missing field quietly turns a pass into a fail. A good chunk of the time spent on the project went into fixing exactly that.

Numbers find candidates, not answers

What the bot is genuinely good at is narrowing 3,300 companies down to a handful that are worth reading about. What it cannot do is tell whether the moat is still there, whether a cheap stock is cheap because the market knows something, or whether management is any good. That part is still reading the annual report, which is, of course, exactly what Buffett would tell you to do.

MoatHunter is an experiment and trades on a paper account only. None of this is financial advice.