Konatus Research Group · free, no sign-in

Conatus Scanner

Read a business by its causes. Every US-listed company with SEC filings, scored on whether its growth follows from its own nature or leans on outside causes.

3,915 companies · filings and prices as of · How it scores

Loading 3,915 companies…

Companies ranked by Conatus score. Select a company for its causal reading.
#

The Spinozan reading

Spinoza’s word for a thing’s effort to persevere in its being is conatus, and he says that effort is nothing but the thing’s actual essence (Ethics III, P7). He also says that knowing an effect depends on, and involves, knowing its cause (I, Axiom 4). A price chart shows effects. Filings show causes: what the business does with capital, and who pays for its growth.

The line this scanner draws comes from Ethics III, Definition 2. We act when something follows from our own nature, of which we are the adequate cause. We are acted upon when it follows only in part from us. A company that compounds on its own cash, without new shares or new debt, is acting. A company whose growth is paid for by fresh stock or borrowed money is, in that measure, being acted upon: it grows because outside causes keep choosing to feed it.

Five causes, twenty points each.
CauseMeasureWhat it asksPoints
PowerReturn on invested capital, latest fiscal year.How much capital the business makes from the capital it holds.5% scores 0, 25% scores 20
PersistenceLowest ROIC of the last five fiscal years.Whether the cause keeps producing the same effect in bad years.0% scores 0, 15% scores 20
Self-fundingShare of years with positive free cash flow, and the latest FCF margin.Whether it pays for its own growth, or needs someone else to.10 for every year positive, 10 for a 15% margin
StrivingRevenue per diluted share, compound growth over up to five years.Growth that reaches each owner, after dilution.0% scores 0, 15% a year scores 20
IndependenceDiluted share count growth, and net debt in years of free cash flow.Whether it lives without new shares or borrowed money.10 for no dilution, 10 for net cash

Bands. 75 and over: strong conatus, acting from its own nature. 50 to 75: striving, partly its own cause. 25 to 50: dependent, leaning on outside causes. Under 25: passive, acted upon.

Price stays out of the score. The FCF yield sits next to it: last fiscal year’s free cash flow divided by today’s market cap. An excellent company can be bought at a poor price, and chapter 20 of The Spinozan Trader says so plainly. The score says what the business is. The yield says what you are paying for it.

Adequate readings. A reading is adequate when at least four fiscal years are on file and ROIC and free cash flow can both be computed. Fewer years is an inadequate idea in Spinoza’s sense: a fragment mistaken for the whole.

What it is not

  • It is not a tested return signal. The book records that quality labels do not establish a premium, that published stock predictors lost about 58% of their returns after publication (McLean and Pontiff), and that the best 4% of listed companies created all of the market’s net wealth (Bessembinder). This measures causes. Whether the market already prices them is a separate question.
  • It reads banks, insurers and REITs poorly. Their debt is their inventory, so ROIC and free cash flow mean something else. They are left out by default.
  • It does not advise anyone to buy or sell anything. It is a reading aid for your own research.

Data and its limits

Financials come from the SEC’s free EDGAR XBRL frames API, as each company tagged them in its 10-K. Prices, market caps and sectors come from Nasdaq’s public stock screener. Both are pulled when the site is built, so the date at the top is the date of the reading.

  • ROIC here is operating income taxed at the 21% US statutory rate, over equity plus debt minus cash, at the fiscal year end. It is simple on purpose, and it will differ from other sites.
  • Share counts are as first filed. Stock splits and unit slips are detected and undone. A change that looks like neither is kept as real dilution, so a rare real doubling that looks like a 2-for-1 split would be missed.
  • Debt comes from standard us-gaap lines. About a quarter of companies tag none, some because they have no debt and some because they use their own tags. Those get half marks on the debt test and a flag, rather than a guess.
  • Each company uses the revenue tag that covers most of its years, so a switch in tagging does not fake growth. Companies that re-registered under a new SEC identity, and foreign filers that report under IFRS, have no history here yet.
  • Unusual readings are flagged in the detail view. Always open the filing before trusting a number.

The TradingView version

The same five causes as a Pine v6 indicator for TradingView’s Pine Screener, built on TradingView’s own fundamentals. The Pine Screener allows five data requests per script, and it uses exactly five. Add it to favorites, open Products, Screeners, Pine, pick a watchlist and set the timeframe to 1W so it can see about nine fiscal years.

Download Conatus_Scanner.txt

Show the Pine source
//@version=6
// Conatus Scanner: the Spinozan way to read a business by its causes.
//
// "The striving by which each thing strives to persevere in its being is nothing
// but the actual essence of the thing" (Ethics III, P7). And: knowledge of an effect
// depends on, and involves, knowledge of its cause (Ethics I, Axiom 4).
//
// A business shows its conatus when its growth follows from its own nature: it
// turns capital into more capital (power), keeps doing so year after year
// (persistence), pays for itself in cash (self-funding), grows what each owner
// holds (striving), and does not need outside causes, new shares or borrowed money,
// to stay alive (independence). Ethics III, Def. 2: we act when a thing follows from
// our nature as its adequate cause, and are acted upon when it follows only in part.
//
// Five causes, 20 points each, 0 to 100:
//   Power         latest ROIC                       5% -> 0 pts, 25% -> 20 pts
//   Persistence   lowest ROIC of the last 5 years   0% -> 0 pts, 15% -> 20 pts
//   Self-funding  share of years with FCF > 0 (10)  + FCF margin 0% -> 0, 15% -> 10
//   Striving      revenue per diluted share CAGR    0% -> 0 pts, 15% -> 20 pts
//   Independence  diluted share CAGR (10)           5% -> 0 pts, 0% or less -> 10
//                 + net debt / FCF (10)             5 yrs -> 0, 0 or net cash -> 10
// Price is kept out of the score and shown beside it as the FCF yield, because an
// excellent company can be bought at a poor price (The Spinozan Trader, ch. 20).
//
// What this is not: a tested return signal. The book (ch. 20) records that quality
// labels do not establish a premium, that published predictors lost about 58% of
// their returns after publication (McLean and Pontiff), and that the best 4% of
// listed companies created all of the market's net wealth (Bessembinder). This
// scanner measures causes. Whether the market already prices them is a separate
// question. ROIC and free cash flow read poorly for banks and insurers.
//
// Pine Screener: add this script to favorites, open Products > Screeners > Pine,
// choose a watchlist or index, and set the timeframe to 1W. The screener computes
// on the last 500 bars only, so 1D sees about two fiscal years and 1W about nine.
// The screener allows at most five request.*() calls, and this script uses exactly five.
indicator("Conatus Scanner", shorttitle = "Conatus", overlay = false, precision = 1)

// ---------------------------------------------------------------- inputs
grpAlert = "Screener alert"
minScore = input.float(70, "Minimum Conatus score", minval = 0, maxval = 100, step = 5, group = grpAlert)
minYield = input.float(3.0, "Minimum FCF yield (%)", minval = -50, step = 0.5, group = grpAlert, tooltip = "Price is kept out of the score. This is the least cash return, at today's price, that you will accept for owning the cause.")
minYears = input.int(4, "Fiscal years needed for an adequate reading", minval = 2, maxval = 6, group = grpAlert, tooltip = "Fewer observed fiscal years than this marks the reading as inadequate, and the alert will not fire.")

grpShow = "Display"
showTable = input.bool(true, "Show causal table", group = grpShow)
colStrong = input.color(color.rgb(38, 166, 154), "Strong", group = grpShow, inline = "c")
colMid = input.color(color.rgb(242, 183, 5), "Striving", group = grpShow, inline = "c")
colWeak = input.color(color.rgb(239, 83, 80), "Passive", group = grpShow, inline = "c")

// ---------------------------------------------------------------- data: exactly five requests
rev = request.financial(syminfo.tickerid, "TOTAL_REVENUE", "FY", ignore_invalid_symbol = true, currency = syminfo.currency)
fcf = request.financial(syminfo.tickerid, "FREE_CASH_FLOW", "FY", ignore_invalid_symbol = true, currency = syminfo.currency)
roic = request.financial(syminfo.tickerid, "RETURN_ON_INVESTED_CAPITAL", "FY", ignore_invalid_symbol = true)
shares = request.financial(syminfo.tickerid, "DILUTED_SHARES_OUTSTANDING", "FY", ignore_invalid_symbol = true)
netDebt = request.financial(syminfo.tickerid, "NET_DEBT", "FY", ignore_invalid_symbol = true, currency = syminfo.currency)

// ---------------------------------------------------------------- helpers
// Keeps the last six distinct fiscal-year values of a stepwise financial series.
fyHistory(float x) =>
    var array<float> h = array.new<float>()
    isNew = not na(x) and (na(x[1]) or x != x[1])
    if isNew
        array.push(h, x)
        if array.size(h) > 6
            array.shift(h)
    h

lastVal(array<float> a) => array.size(a) > 0 ? array.get(a, array.size(a) - 1) : na
backVal(array<float> a, int k) => array.size(a) > k and k >= 0 ? array.get(a, array.size(a) - 1 - k) : na

minLast(array<float> a, int n) =>
    float m = na
    int sz = array.size(a)
    if sz > 0
        for i = math.max(0, sz - n) to sz - 1
            v = array.get(a, i)
            m := na(m) ? v : math.min(m, v)
    m

positiveShare(array<float> a) =>
    int sz = array.size(a)
    float c = 0.0
    if sz > 0
        for i = 0 to sz - 1
            if array.get(a, i) > 0
                c += 1.0
    sz > 0 ? c / sz : na

cagr(float a0, float a1, int k) => na(a0) or na(a1) or k < 1 or a0 <= 0 or a1 <= 0 ? na : math.pow(a1 / a0, 1.0 / k) - 1.0

// Linear points between two anchors, clamped; anchors may run in either direction.
lin(float x, float x0, float x1, float pts) => na(x) ? 0.0 : pts * math.max(0.0, math.min(1.0, (x - x0) / (x1 - x0)))

fmtPct(float x) => na(x) ? "n/a" : str.tostring(x * 100.0, "0.0") + "%"

// ---------------------------------------------------------------- the five causes
revH = fyHistory(rev)
fcfH = fyHistory(fcf)
roicH = fyHistory(roic)
shH = fyHistory(shares)
ndH = fyHistory(netDebt)

roicNow = lastVal(roicH) / 100.0
roicMin = minLast(roicH, 5) / 100.0
fcfNow = lastVal(fcfH)
revNow = lastVal(revH)
fcfMargin = na(fcfNow) or na(revNow) or revNow <= 0 ? na : fcfNow / revNow
fcfPos = positiveShare(fcfH)

k = math.min(math.min(array.size(revH), array.size(shH)) - 1, 5)
rps0 = backVal(revH, k) / backVal(shH, k)
rps1 = revNow / lastVal(shH)
rpsCagr = cagr(rps0, rps1, k)
shCagr = cagr(backVal(shH, k), lastVal(shH), k)

ndNow = lastVal(ndH)
debtYears = na(ndNow) ? na : (ndNow <= 0 ? 0.0 : (not na(fcfNow) and fcfNow > 0 ? ndNow / fcfNow : na))

ptsPower = lin(roicNow, 0.05, 0.25, 20)
ptsPersist = lin(roicMin, 0.0, 0.15, 20)
ptsSelf = (na(fcfPos) ? 0.0 : 10.0 * fcfPos) + lin(fcfMargin, 0.0, 0.15, 10)
ptsStrive = lin(rpsCagr, 0.0, 0.15, 20)
ptsShares = na(shCagr) ? 0.0 : lin(shCagr, 0.05, 0.0, 10)
ptsDebt = na(ndNow) ? 0.0 : (ndNow <= 0 ? 10.0 : (na(debtYears) ? 0.0 : lin(debtYears, 5.0, 0.0, 10)))
ptsIndep = ptsShares + ptsDebt
score = ptsPower + ptsPersist + ptsSelf + ptsStrive + ptsIndep

// The price of the cause, kept out of the score.
fcfYield = na(fcfNow) or na(lastVal(shH)) or lastVal(shH) <= 0 or close <= 0 ? na : fcfNow / (close * lastVal(shH))

yearsSeen = array.size(revH)
adequate = yearsSeen >= minYears and not na(roicNow) and not na(fcfNow)
bandCol = score >= 75 ? colStrong : (score >= 50 ? colMid : colWeak)
bandTxt = score >= 75 ? "Strong conatus: acts from its own nature" : (score >= 50 ? "Striving: partly its own cause" : (score >= 25 ? "Dependent: leans on outside causes" : "Passive: acted upon"))

// ---------------------------------------------------------------- plots (screener columns)
plot(score, "Conatus score", color = adequate ? bandCol : color.new(bandCol, 60), style = plot.style_columns)
plot(ptsPower, "Power pts", color = color.new(color.gray, 100), display = display.data_window)
plot(ptsPersist, "Persistence pts", color = color.new(color.gray, 100), display = display.data_window)
plot(ptsSelf, "Self-funding pts", color = color.new(color.gray, 100), display = display.data_window)
plot(ptsStrive, "Striving pts", color = color.new(color.gray, 100), display = display.data_window)
plot(ptsIndep, "Independence pts", color = color.new(color.gray, 100), display = display.data_window)
plot(roicNow * 100.0, "ROIC %", color = color.new(color.gray, 100), display = display.data_window)
plot(roicMin * 100.0, "Min ROIC 5y %", color = color.new(color.gray, 100), display = display.data_window)
plot(fcfMargin * 100.0, "FCF margin %", color = color.new(color.gray, 100), display = display.data_window)
plot(rpsCagr * 100.0, "Revenue/share CAGR %", color = color.new(color.gray, 100), display = display.data_window)
plot(shCagr * 100.0, "Share count CAGR %", color = color.new(color.gray, 100), display = display.data_window)
plot(debtYears, "Net debt / FCF yrs", color = color.new(color.gray, 100), display = display.data_window)
plot(fcfYield * 100.0, "FCF yield %", color = color.new(color.gray, 100), display = display.data_window)
plot(yearsSeen, "Fiscal years seen", color = color.new(color.gray, 100), display = display.data_window)
hline(75, "Strong", color = color.new(color.gray, 50), linestyle = hline.style_dotted)
hline(50, "Striving", color = color.new(color.gray, 50), linestyle = hline.style_dotted)
hline(25, "Dependent", color = color.new(color.gray, 50), linestyle = hline.style_dotted)

alertcondition(adequate and score >= minScore and not na(fcfYield) and fcfYield * 100.0 >= minYield, "Conatus at a fair price", "Conatus Scanner: {{ticker}} passes the score and FCF-yield thresholds")

// ---------------------------------------------------------------- causal table (chart only)
var table info = table.new(position.top_right, 3, 9, bgcolor = color.new(chart.bg_color, 8), border_color = color.new(chart.fg_color, 80), border_width = 1)
row(int r, string k1, string v, string p, color c) =>
    table.cell(info, 0, r, k1, text_color = color.gray, text_size = size.small, text_halign = text.align_left)
    table.cell(info, 1, r, v, text_color = chart.fg_color, text_size = size.small, text_halign = text.align_left)
    table.cell(info, 2, r, p, text_color = c, text_size = size.small, text_halign = text.align_right)

ptsTxt(float p, float mx) => str.tostring(p, "0.0") + "/" + str.tostring(mx, "0")
ptsCol(float p, float mx) => p >= 0.75 * mx ? colStrong : (p >= 0.4 * mx ? colMid : colWeak)

if showTable and barstate.islast
    row(0, "Conatus", bandTxt, str.tostring(score, "0") + "/100", bandCol)
    row(1, "Power", "ROIC " + fmtPct(roicNow), ptsTxt(ptsPower, 20), ptsCol(ptsPower, 20))
    row(2, "Persistence", "lowest ROIC, " + str.tostring(math.min(array.size(roicH), 5)) + "y: " + fmtPct(roicMin), ptsTxt(ptsPersist, 20), ptsCol(ptsPersist, 20))
    row(3, "Self-funding", "FCF > 0 in " + fmtPct(fcfPos) + " of years; margin " + fmtPct(fcfMargin), ptsTxt(ptsSelf, 20), ptsCol(ptsSelf, 20))
    row(4, "Striving", "revenue per share " + fmtPct(rpsCagr) + " a year over " + str.tostring(math.max(k, 0)) + "y", ptsTxt(ptsStrive, 20), ptsCol(ptsStrive, 20))
    row(5, "Independence", "shares " + fmtPct(shCagr) + " a year; net debt " + (na(ndNow) ? "n/a" : (ndNow <= 0 ? "none (net cash)" : (na(debtYears) ? "with no FCF to repay it" : str.tostring(debtYears, "0.0") + " yrs of FCF"))), ptsTxt(ptsIndep, 20), ptsCol(ptsIndep, 20))
    row(6, "Price of the cause", "FCF yield " + fmtPct(fcfYield) + " at " + str.tostring(close, format.mintick), "", na(fcfYield) ? color.gray : (fcfYield * 100.0 >= minYield ? colStrong : colWeak))
    row(7, "Adequacy", adequate ? str.tostring(yearsSeen) + " fiscal years seen: adequate" : str.tostring(yearsSeen) + " fiscal years seen: inadequate, use 1W", "", adequate ? colStrong : colWeak)
    row(8, "Status", "Measures causes. Not a tested return signal.", "", color.gray)