Skip to content

Commit 1705b8d

Browse files
Ben05-sysclaude
andcommitted
Add payout ratio, is the dividend covered by earnings
Dividend yield alone answers "how much", not "how safe" — a name can carry a high yield precisely because the payout is unsustainable. payout_ratio divides the dividend rate by trailing EPS, the same question every income screen eventually asks: is this being paid out of profit, or out of reserves. Null rather than a misleadingly small positive number when the name is loss-making, matching the guard eps_growth already uses on the same denominator. Classified STATIC_SAFE alongside earnings_yield, whose denominator it shares: both inputs are slow-moving fundamentals, not today's tape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qxoe91wVDqAoyoJxhZ15Vf
1 parent 87c71a4 commit 1705b8d

3 files changed

Lines changed: 38 additions & 1 deletion

File tree

‎app/screen.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"rs_sector", "sector_change_pct",
3838
"dollar_volume", "avg_dollar_volume", "day_range_pct", "gap_pct",
3939
"true_range", "atr_pct", "sma_spread", "intraday_pct",
40-
"earnings_yield", "eps_growth"]
40+
"earnings_yield", "eps_growth", "payout_ratio"]
4141
COLUMNS = [n for n, _ in store.UNIVERSE_COLUMNS] + DERIVED
4242

4343
TEXT_COLUMNS = {"symbol", "name", "sector", "industry", "country",
@@ -93,6 +93,10 @@
9393
# not with the tape — same drift tolerance as the `eps_ttm`/`eps_forward`
9494
# they're built from.
9595
"eps_growth",
96+
# Dividend rate and trailing EPS are both slow-moving fundamentals, not
97+
# today's tape — same drift tolerance as `earnings_yield`, whose
98+
# denominator this shares.
99+
"payout_ratio",
96100
# Average dollar volume is a liquidity floor, not a reading of the
97101
# tape: `avgdvol > 20m` means "this name normally trades enough to get
98102
# out of", and half a day of price drift does not change that answer.
@@ -113,6 +117,7 @@
113117
"eps": "eps_ttm", "fpe": "forward_pe", "pricetobook": "pb",
114118
"eyield": "earnings_yield", "earningsyield": "earnings_yield",
115119
"epsgrowth": "eps_growth", "growth": "eps_growth",
120+
"payout": "payout_ratio", "payoutratio": "payout_ratio",
116121
"div": "dividend_yield", "divyield": "dividend_yield",
117122
"hi52": "week52_high", "lo52": "week52_low",
118123
"high52": "week52_high", "low52": "week52_low",

‎app/universe.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,17 @@ def safe(a, b):
384384
df["peg"] = safe(df["pe"], df["eps_growth"].where(df["eps_growth"] > 0))
385385
else:
386386
df["peg"] = np.nan
387+
# Payout ratio: the dividend as a percent of trailing EPS — is the
388+
# dividend actually covered by earnings, or is it being paid out of
389+
# reserves. Null rather than negative when the name is loss-making:
390+
# dividing by a negative eps_ttm would flip the sign into a small
391+
# positive-looking number that reads like a *safe*, well-covered payout
392+
# when the truth is there is no earnings to cover it from at all.
393+
if "dividend_rate" in df.columns and "eps_ttm" in df.columns:
394+
df["payout_ratio"] = safe(df["dividend_rate"],
395+
df["eps_ttm"].where(df["eps_ttm"] > 0)) * 100
396+
else:
397+
df["payout_ratio"] = np.nan
387398
# ADRs, from the security type Nasdaq puts in the name. Derived rather
388399
# than stored so it works on snapshots taken before the field existed —
389400
# `name` has always been there.

‎tests/test_screen.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,27 @@ def when_for(hour, minute=0):
620620
check("peg tracks the same slow-moving inputs as pe and eps_growth, so it narrows safely stale",
621621
"peg" in screen.STATIC_SAFE and "peg" not in screen.LIVE_COLUMNS)
622622

623+
print("\npayout ratio")
624+
pr = derive(pd.DataFrame({
625+
"symbol": ["COVERED", "LOSER"], "name": ["C", "L"], "sector": ["T"] * 2,
626+
"price": [50.0, 50.0], "change_pct": [0.0] * 2,
627+
"volume": [1e6] * 2, "avg_volume_3m": [1e6] * 2, "market_cap": [1e9] * 2,
628+
"week52_high": [60.0] * 2, "week52_low": [40.0] * 2,
629+
"sma50": [50.0] * 2, "sma200": [50.0] * 2,
630+
"eps_ttm": [4.0, -1.0], "dividend_rate": [1.0, 1.0],
631+
"earnings_ts": [np.nan] * 2,
632+
}))
633+
prrow = dict(zip(pr["symbol"], pr.to_dict("records"), strict=True))
634+
check("profitable name: payout is dividend over trailing eps, as a percent",
635+
abs(prrow["COVERED"]["payout_ratio"] - 25.0) < 1e-9,
636+
prrow["COVERED"]["payout_ratio"])
637+
check("loss-making name: payout stays null rather than a fake positive number",
638+
np.isnan(prrow["LOSER"]["payout_ratio"]), prrow["LOSER"]["payout_ratio"])
639+
check("payout resolves", screen.resolve("payout") == "payout_ratio")
640+
check("payout ratio tracks dividend and trailing eps, not price, so it narrows safely stale",
641+
"payout_ratio" in screen.STATIC_SAFE
642+
and "payout_ratio" not in screen.LIVE_COLUMNS)
643+
623644
# Today's dollar volume only rises, so prefiltering on a stale one would
624645
# drop the names that have since crossed the line. The average is a
625646
# standing fact about the name and narrows safely.

0 commit comments

Comments
 (0)