Skip to content

Commit 33fea32

Browse files
Ben05-sysclaude
andcommitted
Add sma_spread, the golden-cross distance in percent
sma50 > sma200 says which side of the cross a name is on but not how far — a name 0.1% above its 200-day and one 15% above it both just read "true". sma_spread turns that into a sortable percent, so a golden-cross screen can be ranked by how developed the cross is instead of only filtered on the boolean. It depends on the two moving averages alone, not on today's price, so unlike pct_from_sma50/200 it classifies as STATIC_SAFE and narrows before a screen re-prices. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmxGXcSkCKBA9i2sQDoQBk
1 parent a03806a commit 33fea32

3 files changed

Lines changed: 31 additions & 1 deletion

File tree

app/screen.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"earnings_when", "peg", "mktcap", "chg", "is_adr",
3737
"rs_sector", "sector_change_pct",
3838
"dollar_volume", "avg_dollar_volume", "day_range_pct", "gap_pct",
39-
"true_range", "atr_pct"]
39+
"true_range", "atr_pct", "sma_spread"]
4040
COLUMNS = [n for n, _ in store.UNIVERSE_COLUMNS] + DERIVED
4141

4242
TEXT_COLUMNS = {"symbol", "name", "sector", "industry", "country",
@@ -80,6 +80,10 @@
8080
"week52_low", "week52_change_pct", "sma50", "sma200", "avg_volume_3m",
8181
"avg_volume_10d", "analyst_rating", "earnings_ts", "earnings_days",
8282
"earnings_when", "peg", "prev_close", "is_adr",
83+
# Both inputs are themselves slow-moving averages a snapshot old by a
84+
# few hours does not change; unlike `pct_from_sma50` it does not also
85+
# depend on today's price.
86+
"sma_spread",
8387
# Average dollar volume is a liquidity floor, not a reading of the
8488
# tape: `avgdvol > 20m` means "this name normally trades enough to get
8589
# out of", and half a day of price drift does not change that answer.
@@ -105,6 +109,7 @@
105109
"from52high": "pct_from_52w_high", "from52low": "pct_from_52w_low",
106110
"fromsma50": "pct_from_sma50", "fromsma200": "pct_from_sma200",
107111
"sma50pct": "pct_from_sma50", "sma200pct": "pct_from_sma200",
112+
"smaspread": "sma_spread", "crossdist": "sma_spread",
108113
"rating": "analyst_rating", "earnings": "earnings_days",
109114
"ipo": "ipo_year", "type": "quote_type", "state": "market_state",
110115
"lag": "lag_seconds",

app/universe.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,11 @@ def safe(a, b):
306306
df["price"] - df["week52_low"], df["week52_low"]) * 100
307307
df["pct_from_sma50"] = safe(df["price"] - df["sma50"], df["sma50"]) * 100
308308
df["pct_from_sma200"] = safe(df["price"] - df["sma200"], df["sma200"]) * 100
309+
# SMA50 vs SMA200 as a percent apart — the golden-cross distance.
310+
# `sma50 > sma200` alone only says which side of the cross a name is
311+
# on; this is how far, so a screen can sort names by how developed the
312+
# cross is rather than just filtering on the boolean.
313+
df["sma_spread"] = safe(df["sma50"] - df["sma200"], df["sma200"]) * 100
309314
# Relative strength against a stock's own sector. "Up 3%" on a day the
310315
# whole sector rose 3% is not strength; this separates the move from
311316
# the tide. Cap-weighted, because an unweighted sector average is

tests/test_screen.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,26 @@ def when_for(hour, minute=0):
498498
check("atr_pct needs today's range and price, so it isn't prefiltered on a stale one",
499499
"atr_pct" in screen.LIVE_COLUMNS and "atr_pct" not in screen.STATIC_SAFE)
500500

501+
print("\nsma spread")
502+
ss = derive(pd.DataFrame({
503+
"symbol": ["CROSSED", "BELOW"], "name": ["C", "B"], "sector": ["T"] * 2,
504+
"price": [100.0, 100.0], "change_pct": [0.0] * 2,
505+
"volume": [1e6] * 2, "avg_volume_3m": [1e6] * 2, "market_cap": [1e9] * 2,
506+
"week52_high": [110.0] * 2, "week52_low": [90.0] * 2,
507+
"sma50": [110.0, 95.0], "sma200": [100.0, 100.0],
508+
"earnings_ts": [np.nan] * 2,
509+
}))
510+
ssrow = dict(zip(ss["symbol"], ss.to_dict("records"), strict=True))
511+
check("above the 200-day: spread is positive and sized",
512+
abs(ssrow["CROSSED"]["sma_spread"] - 10.0) < 1e-9,
513+
ssrow["CROSSED"]["sma_spread"])
514+
check("below the 200-day: spread is negative",
515+
abs(ssrow["BELOW"]["sma_spread"] - (-5.0)) < 1e-9,
516+
ssrow["BELOW"]["sma_spread"])
517+
check("smaspread resolves", screen.resolve("smaspread") == "sma_spread")
518+
check("sma_spread depends only on two slow-moving averages, so it narrows safely stale",
519+
"sma_spread" in screen.STATIC_SAFE and "sma_spread" not in screen.LIVE_COLUMNS)
520+
501521
# Today's dollar volume only rises, so prefiltering on a stale one would
502522
# drop the names that have since crossed the line. The average is a
503523
# standing fact about the name and narrows safely.

0 commit comments

Comments
 (0)