Fundamental analysis of listed companies, the Ruby way.
Fundamentalista turns a ticker into financial statements, ratios, scores and a valuation, with one API over every data source. It reads the SEC's EDGAR XBRL API, which is free and needs no key and covers US GAAP and IFRS filers alike, or Financial Modeling Prep when you want live quotes and analyst estimates.
company = Fundamentalista.company("AAPL")
year = company.financials.latest
year.income.revenue # => 0.416161e12
year.ratios.roe # => 0.1714e1
year.piotroski.score # => 8
company.financials.growth(:free_cash_flow, years: 3)
valuation = company.valuation(price: 320)
valuation.pe # => 0.4289e2
valuation.fcf_yield # => 0.0209e0
valuation.altman_z.zone # => :safe
valuation.intrinsic_value(growth: 0.06, discount_rate: 0.09)
Every figure is a BigDecimal. Every derived number is nil when a filing lacks an input, so a ratio never raises on an incomplete company. Rates are decimals: 0.25 means 25%. Every object answers to_h with its figures intact and to_json with Floats and ISO dates, so render json: company.financials works in Rails.
gem "fundamentalista"
The SEC asks for a User-Agent naming your application and a contact email on every request. Financial Modeling Prep needs an API key.
Fundamentalista.configure do |config|
config.edgar_user_agent = "Acme Research research@acme.com"
config.fmp_api_key = ENV["FMP_API_KEY"]
config.default_provider = :edgar # or :fmp
end
Fundamentalista.company resolves a ticker through the default provider, or the one you name.
apple = Fundamentalista.company("AAPL")
apple = Fundamentalista.company("AAPL", provider: :fmp)
apple.name # => "Apple Inc."
apple.cik # => 320193
exxon = Fundamentalista.company(34088) # a CIK, for registrants the ticker list no longer carries
ypf = Fundamentalista.company("YPF", provider: [:edgar, :fmp]) # EDGAR first, FMP for what it lacks
A list of providers is a chain: the company comes from the first one that lists it, and every question goes to the first one with an answer, so a 20-F filer gets its annual statements with provenance from EDGAR and its quarters from FMP. config.default_provider takes a list too.
financials returns the reporting history as Financials, an Enumerable of Periods ordered newest first. Each Period carries its income, balance and cash_flow statements and knows the period before it, which is what average balances, growth and the Piotroski score are built on.
financials = apple.financials(limit: 5) # annual
financials = apple.financials(period: :quarterly) # quarters, newest first
ttm = apple.ttm # the trailing twelve months as one Period
financials.latest.income.net_income
financials.series(:revenue) # {2025 => ..., 2024 => ...}
financials.growth(:revenue) # CAGR over the whole history
financials.growth(:revenue, years: 3)
financials.latest.growth(:revenue) # against the prior period
financials.latest.yoy(:revenue) # against the same period a year earlier
financials.year(2024) # one period by fiscal year, or year(2026, 3) for a quarter
financials.latest.metric(:free_cash_flow) # any line item, from whichever statement has it
financials.series(:dividends_paid_per_share) # any of them per share
financials.growth(:free_cash_flow_per_share, years: 5)
Fundamentalista.metrics catalogues every line item metric resolves, as Metric objects with the statement, the unit (:currency, :shares, :per_share or :rate), whether the item is reported or derived, and whether it is a balance at a date or a flow over the period, so an application can offer only what the gem can fill in.
Annual figures go stale for up to a year, so most analysis runs on the trailing twelve months. financials(period: :quarterly) returns quarters with their quarter number, and ttm adds the last four into one Period of type :ttm: flows summed, the diluted share count averaged, the latest balance sheet kept, and the four quarters before as its prior, so ratios on average balances, growth and the Piotroski score all work on it.
ttm = apple.ttm # eight quarters: four for the period, four for its prior
ttm.income.net_income
ttm.ratios.roe
ttm.growth(:revenue) # against the previous twelve months
apple.valuation(price: 320, period: ttm).pe
EDGAR reports quarterly cash flows only year to date, and the fourth quarter only as the annual figure. Fundamentalista reads a quarter directly when the filing has a three month value and derives it otherwise as the difference between two year to date values that share a fiscal year start. Weighted average shares are not additive, so a derived fourth quarter keeps the annual average, and diluted EPS is computed from net income and shares rather than derived.
| Statement | Reported items | Derived when missing |
|---|---|---|
IncomeStatement | revenue, cost of revenue, gross profit, SG&A, operating income, EBIT, interest expense, income before tax, income tax, net income, depreciation and amortization, EBITDA, diluted EPS, diluted shares | gross profit, EBIT, EBITDA, diluted EPS, tax rate |
BalanceSheet | cash, short term investments, receivables, inventory, current assets, PP&E, total assets, payables, current liabilities, total liabilities, short and long term debt, total debt, equity, minority interest, retained earnings, shares outstanding | total liabilities, total debt, liquid assets, net debt, working capital, net working capital |
EBIT is operating income when the company reports one, and income before tax plus interest expense otherwise, which is what pharmaceutical and oil companies leave you; interest coverage, EV/EBIT, Greenblatt's yield and return on capital and the Altman Z-score all use it. Figures follow the line as reported: receivables are trade receivables, PP&E excludes lease right-of-use assets, cost of revenue is the company's own line.
| CashFlowStatement | operating cash flow, capital expenditure, free cash flow, dividends paid, dividends per share, share repurchases | free cash flow, shareholder returns |
Outflows are positive amounts whichever sign the filer gave them: capital_expenditure is what was spent.
Banks have no cost of goods, no inventory and no classified balance sheet, so the classic ratios come back nil for them and the lines that matter live on period.banking: net interest income, interest income, credit provisions, noninterest income and expense, loans, deposits, the allowance for credit losses and the Tier 1 capital ratio. period.bank? says whether the company reports net interest income.
jpm = Fundamentalista.company("JPM").financials.latest
jpm.bank? # => true
jpm.banking.deposits
jpm.ratios.net_interest_margin # net interest income over average total assets
jpm.ratios.efficiency_ratio # noninterest expense over net interest plus noninterest income
jpm.ratios.loan_to_deposit
jpm.ratios.cost_of_risk # credit provisions over average loans
jpm.ratios.allowance_to_loans
The banking and insurance ratios appear in ratios.to_h only for banks and insurers, and the industrial ones that need inventory, cost of revenue or a classified balance sheet come back nil for them. Quarterly ratios are per quarter, not annualized; use the trailing twelve months for returns.
Insurers earn premiums and invest the float, so their lines live on period.insurance: premiums earned and written, claims incurred, total benefits and expenses, investment income, investments, reserves, unearned premiums, premiums receivable and reinsurance recoverables. period.insurer? says whether the company earns premiums, and insurance.float is Buffett's: reserves plus unearned premiums, less what policyholders and reinsurers still owe.
trv = Fundamentalista.company("TRV").financials.latest
trv.ratios.loss_ratio # claims incurred over premiums earned
trv.ratios.combined_ratio # benefits, losses and expenses over premiums earned; below one, underwriting makes money
trv.ratios.expense_ratio # the difference between the two
trv.ratios.investment_yield # investment income over average investments
trv.ratios.float_to_equity
These are property and casualty concepts. For life insurers, whose revenue is mostly investment income and whose reserves are future policy benefits, the combined ratio and the float mislead; read their investment yield and return on equity instead.
period.ratios computes the classic set. Balance sheet denominators use the average of the period and the prior one when a prior is known.
| Group | Ratios |
|---|---|
| Profitability | gross_margin, operating_margin, net_margin, ebitda_margin, roe, roa, roic, return_on_capital |
| Liquidity | current_ratio, quick_ratio, cash_ratio |
| Leverage | debt_to_equity, debt_to_assets, net_debt_to_ebitda, interest_coverage, equity_multiplier |
| Efficiency | asset_turnover, inventory_turnover, receivables_turnover |
| Working capital | days_sales_outstanding, days_inventory_outstanding, days_payables_outstanding, cash_conversion_cycle |
| Cash | fcf_margin, cash_conversion, capex_to_revenue, accrual_ratio |
return_on_capital is Greenblatt's, operating income over net working capital plus fixed assets; pair it with the valuation's ebit_yield to rank companies the magic formula way. accrual_ratio is Sloan's, the share of earnings not backed by operating cash flow. The working capital cycle is in days of the period.
ratios = apple.ratios
ratios.net_margin
ratios.dupont # => {net_margin:, asset_turnover:, equity_multiplier:, roe:}
ratios.to_h
The Piotroski F-score counts nine signals of profitability, leverage and efficiency. Six compare the period with the prior one; without a prior they report nil and the score counts the rest.
score = year.piotroski
score.score # => 8
score.signals # => {positive_income: true, cash_exceeds_income: false, ...}
The Beneish M-score reads eight year-over-year indices for the fingerprints of manipulated earnings: receivables outrunning sales, melting margins, softening asset quality, accruals. Above -1.78 it flags the company.
m = year.beneish
m.score # => -0.229e1
m.likely_manipulator? # => false
m.indices # => {dsri: ..., gmi: ..., aqi: ..., sgi: ..., depi: ..., sgai: ..., tata: ..., lvgi: ...}
year.owner_earnings # Buffett's: net income plus depreciation minus capital expenditure
The Altman Z-score needs the market value of equity, so it lives on the valuation, or takes a market cap directly.
valuation.altman_z.score # => 0.122e2
year.altman_z(market_cap: 4.7e12).zone # => :safe, :grey or :distress
Fundamentalista.compare puts several companies side by side. Any ratio, valuation figure, score or line item goes in a table or a ranking, and magic_formula orders them by Greenblatt's combined rank of earnings yield and return on capital. Pass prices for EDGAR; FMP quotes on its own.
peers = Fundamentalista.compare("AAPL", "MSFT", "KO", prices: { "AAPL" => 320, "MSFT" => 500, "KO" => 70 })
peers.table(:roe, :net_margin, :pe, :piotroski, :altman_z)
peers.rank(:pe, ascending: true)
peers.magic_formula
Tickers the provider cannot resolve are logged and left out, so one bad symbol does not spoil a screen. Comparisons read the latest fiscal year; pass period: :ttm to compare trailing twelve months instead.
Ratios and multiples compare across currencies as they are; amounts do not. Pass currency: and the amounts, revenue, market cap, book value per share and the rest, are converted through exchange Rates, fetched from FMP or given by you on EDGAR, while everything that is a rate or a multiple stays untouched.
rates = Fundamentalista::Rates.new({ "DKK" => 0.156, "EUR" => 1.16 }, to: "USD")
Fundamentalista.compare("AAPL", "NVO", "SAP", currency: "USD", rates: rates).values(:revenue)
Fundamentalista.compare("AAPL", "NVO", currency: "USD", provider: :fmp).table(:revenue, :net_margin) # rates from FMP
Every EDGAR line item remembers where it came from. period.source(:revenue) returns the XBRL concept, the form and filing date it was read from, and whether the figure was reported as is, summed from split concepts, derived from year to date values, or, for a weighted average, carried from the year to date figure.
year.source(:revenue)
# => #<Fundamentalista::Source concept: "RevenueFromContractWithCustomerExcludingAssessedTax", form: "10-K", filed: 2025-10-31, derivation: :reported>
year.source(:short_term_debt).derivation # => :summed
year.sources # every item
EDGAR keeps every value ever filed, so a period can also be read as it stood on a date, before later filings and restatements: apple.financials(as_of: Date.new(2024, 6, 30)) returns the fiscal years filed by then, with the figures of that time. FMP has no history of its own statements and raises UnsupportedPeriodError there.
company.filings lists the company's SEC filings from the submissions index, newest first, each with its form, filing date, the last day of the period it reports, the accession number and the URL of its primary document. lag is the days between the period's end and the filing, which is the company's reporting rhythm: Apple files 34 days after every quarter. company.earnings lists earnings releases from FMP, reported and scheduled, with the actual and estimated EPS and revenue, and next_earnings is the next one on the calendar.
apple.filings(form: "10-Q").first # => #<Fundamentalista::Filing form: "10-Q", filed_on: 2026-07-31, period_ended_on: 2026-06-27>
apple.filings(form: "10-K").map(&:lag) # => [34, 34, 34, ...]
apple.next_earnings # => #<Fundamentalista::EarningsRelease date: 2026-10-29, eps_estimate: 2.05> (FMP)
apple.earnings.last.surprise # EPS over the estimate, as a rate
company.valuation pairs the latest annual period with a market snapshot: the provider's quote, or a price you pass. EDGAR has no market data, so pass a price there.
valuation = apple.valuation(price: 320)
valuation.pe # price over diluted EPS
valuation.forward_pe # price over next year's consensus EPS, or forward_pe(eps: 8.8)
valuation.pb # price over book value per share
valuation.ps # market cap over revenue
valuation.ev_to_ebitda
valuation.ev_to_ebit
valuation.peg # PE over expected EPS growth, in percent points
valuation.earnings_yield
valuation.ebit_yield # Greenblatt's: operating income over enterprise value
valuation.fcf_yield
valuation.dividend_yield
valuation.shareholder_yield # dividends plus buybacks
valuation.graham_number
valuation.to_h
Forward multiples need next year's earnings, which come from analysts, not filings. Financial Modeling Prep serves the consensus; company.estimates lists every year on file and company.estimate is the one after the latest reported period. The valuation picks it up for forward_pe and for peg, whose growth becomes the one the consensus implies. On EDGAR there are no estimates, so pass your own: valuation.forward_pe(eps: 8.8).
estimate = company.estimate # FMP
estimate.eps # consensus average
estimate.eps_low, estimate.eps_high
estimate.analysts # how many stand behind it
estimate.eps_growth(year.income.eps_diluted)
wacc builds a discount rate from the capital structure at the current price: the cost of equity by CAPM from beta, the risk free rate and the equity premium, and the after tax cost of debt from interest expense over total debt, weighted by market value. FMP profiles carry company.beta; on EDGAR pass your own.
rate = valuation.wacc(beta: company.beta, risk_free: 0.04, equity_premium: 0.05)
valuation.intrinsic_value(growth: 0.06, discount_rate: rate)
intrinsic_value grows the period's free cash flow at growth for years, adds a terminal value at terminal_growth, discounts everything at discount_rate, subtracts net debt, and divides by the shares outstanding. margin_of_safety is how far the price sits below that value.
valuation.intrinsic_value(growth: 0.06, discount_rate: 0.09, terminal_growth: 0.025, years: 10)
valuation.margin_of_safety(growth: 0.06, discount_rate: 0.09)
valuation.implied_growth(discount_rate: 0.09) # the reverse DCF: the growth the price is paying for
implied_growth runs the model backwards: it searches for the yearly free cash flow growth at which the intrinsic value equals the price, so you can judge whether the market's assumption is one you would make.
Pass fade: true to any of them and growth declines evenly from growth to terminal_growth across the projection instead of dropping at once, which is how most analysts model a company past its high growth years.
The model is also available on its own, for any cash flow stream:
dcf = Fundamentalista::DCF.new(cash_flow: 98_767_000_000, growth: 0.06, discount_rate: 0.09)
dcf.projection
dcf.present_value
dcf.terminal_value
dcf.value
FMP serves daily closes with their dividend adjusted counterparts as a PriceHistory; on EDGAR you build one yourself from any source. With prices, every fiscal year can be valued at the close of its last day, which is what a history of multiples is.
prices = company.prices(from: Date.new(2020, 1, 1)) # FMP
prices = Fundamentalista::PriceHistory.new([[Date.new(2025, 9, 26), 254.52, 254.52], ...])
prices.at(Date.new(2025, 9, 27)).close # the last close on or before that day
prices.total_return(from: Date.new(2020, 1, 1)) # dividends reinvested
prices.annualized_return
company.valuation_history(:pe, :pb, :fcf_yield) # {2025 => {price:, pe:, pb:, fcf_yield:}, ...}
company.valuation_history(:pe, prices: prices) # with a history you bring
FMP serves revenue by product line and by region for every fiscal year, as Segments: what the company sells and where, and how each part grows. EDGAR's company facts carry no segments.
segments = company.segments(by: :product) # or :geography
segments.latest # {"iPhone" => ..., "Service" => ...}
segments.share # each segment's share of the latest year
segments.growth("Service", years: 5) # CAGR of one segment
Two models complement the DCF. Greenwald's earnings power value capitalizes today's after tax EBIT at the discount rate as if the company never grew, so the gap to the price is the growth being paid for. The residual income model starts from book value and adds only the earnings above the cost of equity's charge on it, so it rewards returns on equity above that cost and nothing else.
valuation.earnings_power_value(discount_rate: 0.09)
valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
| EDGAR | Financial Modeling Prep | |
|---|---|---|
| Cost | Free, no key | API key, free tier available |
| Periods | Annual from 10-K filings, quarterly from 10-Q filings | Annual and quarterly |
| Quotes | No, pass a price | Yes |
| Analyst estimates | No, pass an EPS | Yes |
| Prices and beta | No, bring a PriceHistory and a beta | Yes |
| Revenue segments | No | Yes |
| Exchange rates | No, bring Rates | Yes |
| Statements as of a past date | Yes | No |
| Filings | Yes | No |
| Earnings dates | No | Yes |
| Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
EDGAR publishes every value a company ever tagged, restatements included. Fundamentalista reads each line item for the period it describes and keeps the most recently filed value, while the fiscal year label comes from the original filing. Companies tag the same idea under different XBRL concepts, so each line item has an ordered list of concepts in Providers::Edgar::Tags, and the first one reported wins. Debt excludes lease obligations, which FMP's totalDebt includes.
Foreign filers report under IFRS in 20-F and 40-F filings with their own taxonomy; the same line items map to it, and Period#currency says what the figures are in, nil when the filing reports revenue and assets in no currency at all. Filers that tag several currencies, such as a Chinese company reporting in CNY with USD convenience figures, are read in the currency of their latest statements; a company that changed its reporting currency keeps the years in the new one. Quarterly data is a 10-Q affair, so IFRS filers get annual periods.
The concept map is measured, not guessed. script/coverage reads the latest annual period of the largest SEC filers and reports how often each line item comes back nil; the map grew from what the companies missing an item actually tag. Over the 500 largest filers, the items every valuation rests on are all but complete:
| Line item | Companies without it |
|---|---|
| net income, total liabilities, equity, cash, income tax, operating cash flow | under 2% |
| revenue | 4% |
| diluted EPS, income before tax, interest expense, depreciation and amortization, diluted shares | 5% to 7% |
| shares outstanding, PP&E, capital expenditure | 11% to 14% |
What is missing beyond that is mostly absent from the filing itself: inventory and cost of revenue at service and financial companies, buybacks and dividends at companies that do none, a classified balance sheet at banks and insurers. Companies with no annual filing yet, such as a fresh listing, raise NoFinancialsError from financials.
Every error inherits Fundamentalista::Error. Provider errors are typed: CompanyNotFoundError, UnauthorizedError, RateLimitError, ServerError, each carrying the HTTP response. Transient failures are retried, and EDGAR requests are spaced to the SEC's rate limit.
The figures are as filed. A stock split is restated only in filings made after it, so per share history before the split is in pre-split shares. Companies with several share classes tag the totals per class, so diluted_shares and shares_outstanding can be missing for them. A company in its first year of filing may label two quarters the same way, and one of them is dropped. Expenses a filer negates, such as a released credit provision, keep their sign.
An EDGAR company document runs to several megabytes, so any long-lived process should keep responses. Set config.cache to any store that answers fetch(key) { value }, such as an Active Support cache:
Fundamentalista.configure do |config|
config.cache = ActiveSupport::Cache::FileStore.new("tmp/fundamentalista", expires_in: 1.day)
end
bundle install
bundle exec rake # specs and RuboCop
Specs stub every provider with recorded responses; no keys are needed.
MIT.
8 commits
Ruby
100.0%
Fundamental analysis of listed companies, the Ruby way.
Fundamentalista turns a ticker into financial statements, ratios, scores and a valuation, with one API over every data source. It reads the SEC's EDGAR XBRL API, which is free and needs no key and covers US GAAP and IFRS filers alike, or Financial Modeling Prep when you want live quotes and analyst estimates.
company = Fundamentalista.company("AAPL")
year = company.financials.latest
year.income.revenue # => 0.416161e12
year.ratios.roe # => 0.1714e1
year.piotroski.score # => 8
company.financials.growth(:free_cash_flow, years: 3)
valuation = company.valuation(price: 320)
valuation.pe # => 0.4289e2
valuation.fcf_yield # => 0.0209e0
valuation.altman_z.zone # => :safe
valuation.intrinsic_value(growth: 0.06, discount_rate: 0.09)
Every figure is a BigDecimal. Every derived number is nil when a filing lacks an input, so a ratio never raises on an incomplete company. Rates are decimals: 0.25 means 25%. Every object answers to_h with its figures intact and to_json with Floats and ISO dates, so render json: company.financials works in Rails.
gem "fundamentalista"
The SEC asks for a User-Agent naming your application and a contact email on every request. Financial Modeling Prep needs an API key.
Fundamentalista.configure do |config|
config.edgar_user_agent = "Acme Research research@acme.com"
config.fmp_api_key = ENV["FMP_API_KEY"]
config.default_provider = :edgar # or :fmp
end
Fundamentalista.company resolves a ticker through the default provider, or the one you name.
apple = Fundamentalista.company("AAPL")
apple = Fundamentalista.company("AAPL", provider: :fmp)
apple.name # => "Apple Inc."
apple.cik # => 320193
exxon = Fundamentalista.company(34088) # a CIK, for registrants the ticker list no longer carries
ypf = Fundamentalista.company("YPF", provider: [:edgar, :fmp]) # EDGAR first, FMP for what it lacks
A list of providers is a chain: the company comes from the first one that lists it, and every question goes to the first one with an answer, so a 20-F filer gets its annual statements with provenance from EDGAR and its quarters from FMP. config.default_provider takes a list too.
financials returns the reporting history as Financials, an Enumerable of Periods ordered newest first. Each Period carries its income, balance and cash_flow statements and knows the period before it, which is what average balances, growth and the Piotroski score are built on.
financials = apple.financials(limit: 5) # annual
financials = apple.financials(period: :quarterly) # quarters, newest first
ttm = apple.ttm # the trailing twelve months as one Period
financials.latest.income.net_income
financials.series(:revenue) # {2025 => ..., 2024 => ...}
financials.growth(:revenue) # CAGR over the whole history
financials.growth(:revenue, years: 3)
financials.latest.growth(:revenue) # against the prior period
financials.latest.yoy(:revenue) # against the same period a year earlier
financials.year(2024) # one period by fiscal year, or year(2026, 3) for a quarter
financials.latest.metric(:free_cash_flow) # any line item, from whichever statement has it
financials.series(:dividends_paid_per_share) # any of them per share
financials.growth(:free_cash_flow_per_share, years: 5)
Fundamentalista.metrics catalogues every line item metric resolves, as Metric objects with the statement, the unit (:currency, :shares, :per_share or :rate), whether the item is reported or derived, and whether it is a balance at a date or a flow over the period, so an application can offer only what the gem can fill in.
Annual figures go stale for up to a year, so most analysis runs on the trailing twelve months. financials(period: :quarterly) returns quarters with their quarter number, and ttm adds the last four into one Period of type :ttm: flows summed, the diluted share count averaged, the latest balance sheet kept, and the four quarters before as its prior, so ratios on average balances, growth and the Piotroski score all work on it.
ttm = apple.ttm # eight quarters: four for the period, four for its prior
ttm.income.net_income
ttm.ratios.roe
ttm.growth(:revenue) # against the previous twelve months
apple.valuation(price: 320, period: ttm).pe
EDGAR reports quarterly cash flows only year to date, and the fourth quarter only as the annual figure. Fundamentalista reads a quarter directly when the filing has a three month value and derives it otherwise as the difference between two year to date values that share a fiscal year start. Weighted average shares are not additive, so a derived fourth quarter keeps the annual average, and diluted EPS is computed from net income and shares rather than derived.
| Statement | Reported items | Derived when missing |
|---|---|---|
IncomeStatement | revenue, cost of revenue, gross profit, SG&A, operating income, EBIT, interest expense, income before tax, income tax, net income, depreciation and amortization, EBITDA, diluted EPS, diluted shares | gross profit, EBIT, EBITDA, diluted EPS, tax rate |
BalanceSheet | cash, short term investments, receivables, inventory, current assets, PP&E, total assets, payables, current liabilities, total liabilities, short and long term debt, total debt, equity, minority interest, retained earnings, shares outstanding | total liabilities, total debt, liquid assets, net debt, working capital, net working capital |
EBIT is operating income when the company reports one, and income before tax plus interest expense otherwise, which is what pharmaceutical and oil companies leave you; interest coverage, EV/EBIT, Greenblatt's yield and return on capital and the Altman Z-score all use it. Figures follow the line as reported: receivables are trade receivables, PP&E excludes lease right-of-use assets, cost of revenue is the company's own line.
| CashFlowStatement | operating cash flow, capital expenditure, free cash flow, dividends paid, dividends per share, share repurchases | free cash flow, shareholder returns |
Outflows are positive amounts whichever sign the filer gave them: capital_expenditure is what was spent.
Banks have no cost of goods, no inventory and no classified balance sheet, so the classic ratios come back nil for them and the lines that matter live on period.banking: net interest income, interest income, credit provisions, noninterest income and expense, loans, deposits, the allowance for credit losses and the Tier 1 capital ratio. period.bank? says whether the company reports net interest income.
jpm = Fundamentalista.company("JPM").financials.latest
jpm.bank? # => true
jpm.banking.deposits
jpm.ratios.net_interest_margin # net interest income over average total assets
jpm.ratios.efficiency_ratio # noninterest expense over net interest plus noninterest income
jpm.ratios.loan_to_deposit
jpm.ratios.cost_of_risk # credit provisions over average loans
jpm.ratios.allowance_to_loans
The banking and insurance ratios appear in ratios.to_h only for banks and insurers, and the industrial ones that need inventory, cost of revenue or a classified balance sheet come back nil for them. Quarterly ratios are per quarter, not annualized; use the trailing twelve months for returns.
Insurers earn premiums and invest the float, so their lines live on period.insurance: premiums earned and written, claims incurred, total benefits and expenses, investment income, investments, reserves, unearned premiums, premiums receivable and reinsurance recoverables. period.insurer? says whether the company earns premiums, and insurance.float is Buffett's: reserves plus unearned premiums, less what policyholders and reinsurers still owe.
trv = Fundamentalista.company("TRV").financials.latest
trv.ratios.loss_ratio # claims incurred over premiums earned
trv.ratios.combined_ratio # benefits, losses and expenses over premiums earned; below one, underwriting makes money
trv.ratios.expense_ratio # the difference between the two
trv.ratios.investment_yield # investment income over average investments
trv.ratios.float_to_equity
These are property and casualty concepts. For life insurers, whose revenue is mostly investment income and whose reserves are future policy benefits, the combined ratio and the float mislead; read their investment yield and return on equity instead.
period.ratios computes the classic set. Balance sheet denominators use the average of the period and the prior one when a prior is known.
| Group | Ratios |
|---|---|
| Profitability | gross_margin, operating_margin, net_margin, ebitda_margin, roe, roa, roic, return_on_capital |
| Liquidity | current_ratio, quick_ratio, cash_ratio |
| Leverage | debt_to_equity, debt_to_assets, net_debt_to_ebitda, interest_coverage, equity_multiplier |
| Efficiency | asset_turnover, inventory_turnover, receivables_turnover |
| Working capital | days_sales_outstanding, days_inventory_outstanding, days_payables_outstanding, cash_conversion_cycle |
| Cash | fcf_margin, cash_conversion, capex_to_revenue, accrual_ratio |
return_on_capital is Greenblatt's, operating income over net working capital plus fixed assets; pair it with the valuation's ebit_yield to rank companies the magic formula way. accrual_ratio is Sloan's, the share of earnings not backed by operating cash flow. The working capital cycle is in days of the period.
ratios = apple.ratios
ratios.net_margin
ratios.dupont # => {net_margin:, asset_turnover:, equity_multiplier:, roe:}
ratios.to_h
The Piotroski F-score counts nine signals of profitability, leverage and efficiency. Six compare the period with the prior one; without a prior they report nil and the score counts the rest.
score = year.piotroski
score.score # => 8
score.signals # => {positive_income: true, cash_exceeds_income: false, ...}
The Beneish M-score reads eight year-over-year indices for the fingerprints of manipulated earnings: receivables outrunning sales, melting margins, softening asset quality, accruals. Above -1.78 it flags the company.
m = year.beneish
m.score # => -0.229e1
m.likely_manipulator? # => false
m.indices # => {dsri: ..., gmi: ..., aqi: ..., sgi: ..., depi: ..., sgai: ..., tata: ..., lvgi: ...}
year.owner_earnings # Buffett's: net income plus depreciation minus capital expenditure
The Altman Z-score needs the market value of equity, so it lives on the valuation, or takes a market cap directly.
valuation.altman_z.score # => 0.122e2
year.altman_z(market_cap: 4.7e12).zone # => :safe, :grey or :distress
Fundamentalista.compare puts several companies side by side. Any ratio, valuation figure, score or line item goes in a table or a ranking, and magic_formula orders them by Greenblatt's combined rank of earnings yield and return on capital. Pass prices for EDGAR; FMP quotes on its own.
peers = Fundamentalista.compare("AAPL", "MSFT", "KO", prices: { "AAPL" => 320, "MSFT" => 500, "KO" => 70 })
peers.table(:roe, :net_margin, :pe, :piotroski, :altman_z)
peers.rank(:pe, ascending: true)
peers.magic_formula
Tickers the provider cannot resolve are logged and left out, so one bad symbol does not spoil a screen. Comparisons read the latest fiscal year; pass period: :ttm to compare trailing twelve months instead.
Ratios and multiples compare across currencies as they are; amounts do not. Pass currency: and the amounts, revenue, market cap, book value per share and the rest, are converted through exchange Rates, fetched from FMP or given by you on EDGAR, while everything that is a rate or a multiple stays untouched.
rates = Fundamentalista::Rates.new({ "DKK" => 0.156, "EUR" => 1.16 }, to: "USD")
Fundamentalista.compare("AAPL", "NVO", "SAP", currency: "USD", rates: rates).values(:revenue)
Fundamentalista.compare("AAPL", "NVO", currency: "USD", provider: :fmp).table(:revenue, :net_margin) # rates from FMP
Every EDGAR line item remembers where it came from. period.source(:revenue) returns the XBRL concept, the form and filing date it was read from, and whether the figure was reported as is, summed from split concepts, derived from year to date values, or, for a weighted average, carried from the year to date figure.
year.source(:revenue)
# => #<Fundamentalista::Source concept: "RevenueFromContractWithCustomerExcludingAssessedTax", form: "10-K", filed: 2025-10-31, derivation: :reported>
year.source(:short_term_debt).derivation # => :summed
year.sources # every item
EDGAR keeps every value ever filed, so a period can also be read as it stood on a date, before later filings and restatements: apple.financials(as_of: Date.new(2024, 6, 30)) returns the fiscal years filed by then, with the figures of that time. FMP has no history of its own statements and raises UnsupportedPeriodError there.
company.filings lists the company's SEC filings from the submissions index, newest first, each with its form, filing date, the last day of the period it reports, the accession number and the URL of its primary document. lag is the days between the period's end and the filing, which is the company's reporting rhythm: Apple files 34 days after every quarter. company.earnings lists earnings releases from FMP, reported and scheduled, with the actual and estimated EPS and revenue, and next_earnings is the next one on the calendar.
apple.filings(form: "10-Q").first # => #<Fundamentalista::Filing form: "10-Q", filed_on: 2026-07-31, period_ended_on: 2026-06-27>
apple.filings(form: "10-K").map(&:lag) # => [34, 34, 34, ...]
apple.next_earnings # => #<Fundamentalista::EarningsRelease date: 2026-10-29, eps_estimate: 2.05> (FMP)
apple.earnings.last.surprise # EPS over the estimate, as a rate
company.valuation pairs the latest annual period with a market snapshot: the provider's quote, or a price you pass. EDGAR has no market data, so pass a price there.
valuation = apple.valuation(price: 320)
valuation.pe # price over diluted EPS
valuation.forward_pe # price over next year's consensus EPS, or forward_pe(eps: 8.8)
valuation.pb # price over book value per share
valuation.ps # market cap over revenue
valuation.ev_to_ebitda
valuation.ev_to_ebit
valuation.peg # PE over expected EPS growth, in percent points
valuation.earnings_yield
valuation.ebit_yield # Greenblatt's: operating income over enterprise value
valuation.fcf_yield
valuation.dividend_yield
valuation.shareholder_yield # dividends plus buybacks
valuation.graham_number
valuation.to_h
Forward multiples need next year's earnings, which come from analysts, not filings. Financial Modeling Prep serves the consensus; company.estimates lists every year on file and company.estimate is the one after the latest reported period. The valuation picks it up for forward_pe and for peg, whose growth becomes the one the consensus implies. On EDGAR there are no estimates, so pass your own: valuation.forward_pe(eps: 8.8).
estimate = company.estimate # FMP
estimate.eps # consensus average
estimate.eps_low, estimate.eps_high
estimate.analysts # how many stand behind it
estimate.eps_growth(year.income.eps_diluted)
wacc builds a discount rate from the capital structure at the current price: the cost of equity by CAPM from beta, the risk free rate and the equity premium, and the after tax cost of debt from interest expense over total debt, weighted by market value. FMP profiles carry company.beta; on EDGAR pass your own.
rate = valuation.wacc(beta: company.beta, risk_free: 0.04, equity_premium: 0.05)
valuation.intrinsic_value(growth: 0.06, discount_rate: rate)
intrinsic_value grows the period's free cash flow at growth for years, adds a terminal value at terminal_growth, discounts everything at discount_rate, subtracts net debt, and divides by the shares outstanding. margin_of_safety is how far the price sits below that value.
valuation.intrinsic_value(growth: 0.06, discount_rate: 0.09, terminal_growth: 0.025, years: 10)
valuation.margin_of_safety(growth: 0.06, discount_rate: 0.09)
valuation.implied_growth(discount_rate: 0.09) # the reverse DCF: the growth the price is paying for
implied_growth runs the model backwards: it searches for the yearly free cash flow growth at which the intrinsic value equals the price, so you can judge whether the market's assumption is one you would make.
Pass fade: true to any of them and growth declines evenly from growth to terminal_growth across the projection instead of dropping at once, which is how most analysts model a company past its high growth years.
The model is also available on its own, for any cash flow stream:
dcf = Fundamentalista::DCF.new(cash_flow: 98_767_000_000, growth: 0.06, discount_rate: 0.09)
dcf.projection
dcf.present_value
dcf.terminal_value
dcf.value
FMP serves daily closes with their dividend adjusted counterparts as a PriceHistory; on EDGAR you build one yourself from any source. With prices, every fiscal year can be valued at the close of its last day, which is what a history of multiples is.
prices = company.prices(from: Date.new(2020, 1, 1)) # FMP
prices = Fundamentalista::PriceHistory.new([[Date.new(2025, 9, 26), 254.52, 254.52], ...])
prices.at(Date.new(2025, 9, 27)).close # the last close on or before that day
prices.total_return(from: Date.new(2020, 1, 1)) # dividends reinvested
prices.annualized_return
company.valuation_history(:pe, :pb, :fcf_yield) # {2025 => {price:, pe:, pb:, fcf_yield:}, ...}
company.valuation_history(:pe, prices: prices) # with a history you bring
FMP serves revenue by product line and by region for every fiscal year, as Segments: what the company sells and where, and how each part grows. EDGAR's company facts carry no segments.
segments = company.segments(by: :product) # or :geography
segments.latest # {"iPhone" => ..., "Service" => ...}
segments.share # each segment's share of the latest year
segments.growth("Service", years: 5) # CAGR of one segment
Two models complement the DCF. Greenwald's earnings power value capitalizes today's after tax EBIT at the discount rate as if the company never grew, so the gap to the price is the growth being paid for. The residual income model starts from book value and adds only the earnings above the cost of equity's charge on it, so it rewards returns on equity above that cost and nothing else.
valuation.earnings_power_value(discount_rate: 0.09)
valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
| EDGAR | Financial Modeling Prep | |
|---|---|---|
| Cost | Free, no key | API key, free tier available |
| Periods | Annual from 10-K filings, quarterly from 10-Q filings | Annual and quarterly |
| Quotes | No, pass a price | Yes |
| Analyst estimates | No, pass an EPS | Yes |
| Prices and beta | No, bring a PriceHistory and a beta | Yes |
| Revenue segments | No | Yes |
| Exchange rates | No, bring Rates | Yes |
| Statements as of a past date | Yes | No |
| Filings | Yes | No |
| Earnings dates | No | Yes |
| Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
EDGAR publishes every value a company ever tagged, restatements included. Fundamentalista reads each line item for the period it describes and keeps the most recently filed value, while the fiscal year label comes from the original filing. Companies tag the same idea under different XBRL concepts, so each line item has an ordered list of concepts in Providers::Edgar::Tags, and the first one reported wins. Debt excludes lease obligations, which FMP's totalDebt includes.
Foreign filers report under IFRS in 20-F and 40-F filings with their own taxonomy; the same line items map to it, and Period#currency says what the figures are in, nil when the filing reports revenue and assets in no currency at all. Filers that tag several currencies, such as a Chinese company reporting in CNY with USD convenience figures, are read in the currency of their latest statements; a company that changed its reporting currency keeps the years in the new one. Quarterly data is a 10-Q affair, so IFRS filers get annual periods.
The concept map is measured, not guessed. script/coverage reads the latest annual period of the largest SEC filers and reports how often each line item comes back nil; the map grew from what the companies missing an item actually tag. Over the 500 largest filers, the items every valuation rests on are all but complete:
| Line item | Companies without it |
|---|---|
| net income, total liabilities, equity, cash, income tax, operating cash flow | under 2% |
| revenue | 4% |
| diluted EPS, income before tax, interest expense, depreciation and amortization, diluted shares | 5% to 7% |
| shares outstanding, PP&E, capital expenditure | 11% to 14% |
What is missing beyond that is mostly absent from the filing itself: inventory and cost of revenue at service and financial companies, buybacks and dividends at companies that do none, a classified balance sheet at banks and insurers. Companies with no annual filing yet, such as a fresh listing, raise NoFinancialsError from financials.
Every error inherits Fundamentalista::Error. Provider errors are typed: CompanyNotFoundError, UnauthorizedError, RateLimitError, ServerError, each carrying the HTTP response. Transient failures are retried, and EDGAR requests are spaced to the SEC's rate limit.
The figures are as filed. A stock split is restated only in filings made after it, so per share history before the split is in pre-split shares. Companies with several share classes tag the totals per class, so diluted_shares and shares_outstanding can be missing for them. A company in its first year of filing may label two quarters the same way, and one of them is dropped. Expenses a filer negates, such as a released credit provision, keep their sign.
An EDGAR company document runs to several megabytes, so any long-lived process should keep responses. Set config.cache to any store that answers fetch(key) { value }, such as an Active Support cache:
Fundamentalista.configure do |config|
config.cache = ActiveSupport::Cache::FileStore.new("tmp/fundamentalista", expires_in: 1.day)
end
bundle install
bundle exec rake # specs and RuboCop
Specs stub every provider with recorded responses; no keys are needed.
MIT.
8 commits
Ruby
100.0%