//@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 h = array.new() 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 a) => array.size(a) > 0 ? array.get(a, array.size(a) - 1) : na backVal(array a, int k) => array.size(a) > k and k >= 0 ? array.get(a, array.size(a) - 1 - k) : na minLast(array 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 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)