Skip to content

Commit e9e9c1f

Browse files
Ben05-sysclaude
andcommitted
Add volume trend, the last two weeks against the last quarter
rel_volume answers "is today unusually busy for this name" against its own average. It doesn't say whether that elevation is new: a name that has been running hot for two weeks has already lifted its own 10-day average, so a rel_volume near 1 today can still sit on top of a base that is well above the quarter's norm. volume_trend is avg_volume_10d over avg_volume_3m as a percent — 100 is business as usual, above says the recent pace has been running hot against the longer baseline, below says it has gone quiet. Both inputs are Yahoo's own rolling averages, not today's tape, so it classifies STATIC_SAFE the same way sma_spread does. Synthetic test in test_screen.py covers both directions by hand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5vZPCuSPyqkdbXKa3UrEV
1 parent 34d4433 commit e9e9c1f

3 files changed

Lines changed: 36 additions & 1 deletion

File tree

‎app/screen.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"dollar_volume", "avg_dollar_volume", "day_range_pct", "gap_pct",
4040
"gap_filled", "true_range", "atr_pct", "sma_spread",
4141
"intraday_pct", "earnings_yield", "eps_growth", "payout_ratio",
42-
"turnover_pct"]
42+
"turnover_pct", "volume_trend"]
4343

4444
# Joined onto the frame from the corporate calendar (app/calendars.py)
4545
# rather than read from the snapshot, plus what derive() computes from
@@ -128,6 +128,9 @@
128128
# `volume` is — it only rises, so a stale value drops the very names
129129
# that have since crossed the threshold.
130130
"avg_dollar_volume",
131+
# Two rolling averages, same drift tolerance as `sma_spread`. Today's
132+
# raw `volume` is deliberately absent, same reasoning as the line above.
133+
"volume_trend",
131134
# The corporate calendar. A declared dividend's four dates and its
132135
# amount are fixed the moment it is announced and do not move again,
133136
# so they are the most static fields here — safe to narrow on before
@@ -179,6 +182,7 @@
179182
"avgdvol": "avg_dollar_volume", "avgdollarvol": "avg_dollar_volume",
180183
"liquidity": "avg_dollar_volume",
181184
"turnoverpct": "turnover_pct", "floatturnover": "turnover_pct",
185+
"voltrend": "volume_trend", "volumetrend": "volume_trend",
182186
"dayrange": "day_range_pct", "rangepos": "day_range_pct",
183187
"inrange": "day_range_pct", "gap": "gap_pct",
184188
"intraday": "intraday_pct", "sinceopen": "intraday_pct",

‎app/universe.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,14 @@ def safe(a, b):
270270
df["turnover_pct"] = safe(df["volume"], df["shares_outstanding"]) * 100
271271
else:
272272
df["turnover_pct"] = np.nan
273+
# The last two weeks' average volume against the last quarter's, as a
274+
# percent: 100 is business as usual. Distinct from `rel_volume`, which
275+
# is today alone against the average — this says whether an elevation
276+
# has already become the new normal or is still fresh.
277+
if "avg_volume_10d" in df.columns and "avg_volume_3m" in df.columns:
278+
df["volume_trend"] = safe(df["avg_volume_10d"], df["avg_volume_3m"]) * 100
279+
else:
280+
df["volume_trend"] = np.nan
273281
# Where the last price sits in the day's range, 0 at the low and 100 at
274282
# the high. A stock closing at 95 is a different tape from one closing
275283
# at 15 on the same percentage change. NaN, not 50, when the range has

‎tests/test_screen.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,29 @@ def when_for(hour, minute=0):
706706
"turnover_pct" in screen.LIVE_COLUMNS
707707
and "turnover_pct" not in screen.STATIC_SAFE)
708708

709+
print("\nvolume trend")
710+
vt = derive(pd.DataFrame({
711+
"symbol": ["HOT", "QUIET"], "name": ["H", "Q"], "sector": ["T"] * 2,
712+
"price": [50.0, 50.0], "change_pct": [0.0] * 2,
713+
"volume": [1e6] * 2, "market_cap": [1e9] * 2,
714+
"week52_high": [60.0] * 2, "week52_low": [40.0] * 2,
715+
"sma50": [50.0] * 2, "sma200": [50.0] * 2,
716+
"avg_volume_10d": [3e6, 0.5e6], "avg_volume_3m": [2e6, 2e6],
717+
"earnings_ts": [np.nan] * 2,
718+
}))
719+
vtrow = dict(zip(vt["symbol"], vt.to_dict("records"), strict=True))
720+
check("recent pace running above the quarterly baseline",
721+
abs(vtrow["HOT"]["volume_trend"] - 150.0) < 1e-9,
722+
vtrow["HOT"]["volume_trend"])
723+
check("recent pace running below the quarterly baseline",
724+
abs(vtrow["QUIET"]["volume_trend"] - 25.0) < 1e-9,
725+
vtrow["QUIET"]["volume_trend"])
726+
check("voltrend resolves", screen.resolve("voltrend") == "volume_trend")
727+
check("volume trend tracks two rolling averages, not today's tape, so "
728+
"it narrows safely stale",
729+
"volume_trend" in screen.STATIC_SAFE
730+
and "volume_trend" not in screen.LIVE_COLUMNS)
731+
709732
# Today's dollar volume only rises, so prefiltering on a stale one would
710733
# drop the names that have since crossed the line. The average is a
711734
# standing fact about the name and narrows safely.

0 commit comments

Comments
 (0)