Relative Volume Screener in SQL: Free API
Build a relative volume screener in SQL, with the time of day correction the RVOL formula needs, one curl call, no API key, and a ranked top 20 list.
A relative volume screener ranks stocks by how heavily they are trading right now against how heavily they normally trade by this point in the session. It needs two pieces: a per symbol baseline measured at the same minute of the day, and today's cumulative volume to divide by it. Everything below runs from one curl call against a free SQL API, with no key and no paid feed, and it fixes the time of day error that most published RVOL formulas carry.
What a relative volume screener actually divides
Relative volume, usually written RVOL, is a single division. The numerator is volume so far: shares traded in the regular session, 9:30 a.m. to 4:00 p.m. ET, from the open through the current minute. The denominator is whatever counts as normal for that same stretch of the clock, averaged over a lookback window, 20 sessions in most conventions.
Almost every copy of the relative volume change formula on the open web gets the denominator wrong. It divides today's partial volume by the average of twenty COMPLETE sessions, which sets a fraction of one day against the whole of twenty others. The error is not a constant you can multiply out later. Its size changes minute by minute through the session, and it changes from symbol to symbol. What relative volume measures lays out the definition in plain terms. This post turns the corrected version into SQL you can run.
Why the naive relative volume formula breaks before the close
Volume is not spread evenly across the day. The panel below takes recent complete sessions, averages each half hour bucket, and reports the share of the regular session that is finished by each point on the clock.
| et_time | spy_share_pct | ko_share_pct |
|---|---|---|
| 09:30 | 11.5 | 14.6 |
| 10:00 | 19.8 | 23.3 |
| 10:30 | 26.7 | 30.2 |
| 11:00 | 34.8 | 37.1 |
| 11:30 | 40.6 | 42.4 |
| 12:00 | 45.3 | 47.6 |
| 12:30 | 49.5 | 52.2 |
| 13:00 | 54.2 | 56.7 |
| 13:30 | 58.1 | 60.8 |
| 14:00 | 63.2 | 65.6 |
| 14:30 | 70.2 | 71.4 |
| 15:00 | 77.6 | 78.4 |
| 15:30 | 100 | 100 |
The exact SQL behind every number
WITH
bars AS (
SELECT
ticker,
toDate(et_ts) AS session_date,
formatDateTime(toStartOfInterval(et_ts, INTERVAL 30 MINUTE), '%H:%i') AS et_time,
toHour(et_ts) * 60 + toMinute(et_ts) AS et_min,
toFloat64(volume) AS vol
FROM
(
SELECT
ticker,
toTimeZone(window_start, 'America/New_York') AS et_ts,
volume
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'KO')
AND window_start >= toDateTime(today() - 40)
)
),
slots AS (
SELECT
ticker,
session_date,
et_time,
min(et_min) AS slot_min,
sum(vol) AS slot_vol
FROM bars
WHERE et_min >= 570 AND et_min < 960
GROUP BY ticker, session_date, et_time
),
complete AS (
SELECT ticker, session_date
FROM slots
GROUP BY ticker, session_date
HAVING max(slot_min) >= 930
),
slot_avg AS (
SELECT
ticker,
et_time,
min(slot_min) AS slot_min,
avg(slot_vol) AS avg_slot_vol
FROM slots
WHERE (ticker, session_date) IN (SELECT ticker, session_date FROM complete)
GROUP BY ticker, et_time
),
cum AS (
SELECT
ticker,
et_time,
slot_min,
sum(avg_slot_vol) OVER (PARTITION BY ticker ORDER BY slot_min) AS cum_vol,
sum(avg_slot_vol) OVER (PARTITION BY ticker) AS day_vol
FROM slot_avg
)
SELECT
et_time,
round(100 * maxIf(cum_vol / day_vol, ticker = 'SPY'), 1) AS spy_share_pct,
round(100 * maxIf(cum_vol / day_vol, ticker = 'KO'), 1) AS ko_share_pct
FROM cum
GROUP BY et_time
ORDER BY min(slot_min)By 11:00 a.m. ET, 34.8% of SPY's average session volume has printed, with KO at 37.1%. The opening half hour alone carries 11.5% of SPY's day. The final bucket carries the step from 77.6% to 100, where the closing auction lands, which is what makes the last segment of the line so steep.
Now hold that 11:00 number against the naive formula. Dividing a mid morning volume by a full day average shrinks every reading to roughly a third of its honest value. A stock running at three times its own normal morning pace prints a 1.0, which reads as a completely ordinary day, and it never surfaces on a list sorted by RVOL.
What the correction looks like on one stock
The panel below pins Cisco to its September 22, 2026 session and recomputes RVOL both ways at every half hour, against the 20 sessions before it. One line divides by the same time average. The other divides by the average full day.
| et_time | rvol_time_adjusted | rvol_naive |
|---|---|---|
| 09:30 | 1.67 | 0.26 |
| 10:00 | 2.18 | 0.53 |
| 10:30 | 2.49 | 0.76 |
| 11:00 | 2.49 | 0.91 |
| 11:30 | 2.75 | 1.15 |
| 12:00 | 2.81 | 1.31 |
| 12:30 | 2.83 | 1.45 |
| 13:00 | 2.91 | 1.61 |
| 13:30 | 2.99 | 1.78 |
| 14:00 | 3.08 | 1.98 |
| 14:30 | 2.97 | 2.09 |
| 15:00 | 2.87 | 2.2 |
| 15:30 | 2.59 | 2.59 |
The exact SQL behind every number
WITH
bars AS (
SELECT
toDate(et_ts) AS session_date,
formatDateTime(toStartOfInterval(et_ts, INTERVAL 30 MINUTE), '%H:%i') AS et_time,
toHour(et_ts) * 60 + toMinute(et_ts) AS et_min,
toFloat64(volume) AS vol
FROM
(
SELECT
toTimeZone(window_start, 'America/New_York') AS et_ts,
volume
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'CSCO'
AND window_start >= '2026-08-24'
AND window_start < '2026-09-23'
)
),
slots AS (
SELECT
session_date,
et_time,
min(et_min) AS slot_min,
sum(vol) AS slot_vol
FROM bars
WHERE et_min >= 570 AND et_min < 960
GROUP BY session_date, et_time
),
cum AS (
SELECT
session_date,
et_time,
slot_min,
sum(slot_vol) OVER (PARTITION BY session_date ORDER BY slot_min) AS cum_vol
FROM slots
),
base AS (
SELECT avg(day_vol) AS avg_day_vol
FROM
(
SELECT session_date, sum(slot_vol) AS day_vol
FROM slots
WHERE session_date < '2026-09-22'
GROUP BY session_date
)
)
SELECT
et_time,
round(maxIf(cum_vol, session_date = '2026-09-22') / avgIf(cum_vol, session_date < '2026-09-22'), 2) AS rvol_time_adjusted,
round(maxIf(cum_vol, session_date = '2026-09-22') / (SELECT avg_day_vol FROM base), 2) AS rvol_naive
FROM cum
GROUP BY et_time
ORDER BY min(slot_min)In the opening half hour the naive measure reads 0.26 while the corrected one reads 1.67. At 11:00 the gap is still wide: 0.91 against 2.49. A screener reading the first number files this session under quiet and moves on. The second number says the stock had already traded 2.49 times its normal morning volume.
The two lines meet in the final half hour bucket, both at 2.59. Once the session is over, the average full day IS the same time average, and the naive formula becomes correct. Before then it behaves like a progress bar, climbing toward the true reading and arriving only at the close.
How to build a relative volume screener with curl and jq
The demo SQL endpoint takes a query string and returns JSON. No key, no signup, no headers. Three commands, in the order you would work:
- Install the tools. In a fresh Debian or Ubuntu container, as root:
apt-get update && apt-get install -y curl jq. On Alpine:apk add --no-cache curl jq. - Smoke test the endpoint and see how current the data is:
curl -sG https://ai.strasmore.com/api/demo/sql --data-urlencode "sql=SELECT toString(max(date)) AS last_day FROM global_markets.stocks_daily_aggs WHERE ticker = 'SPY'" | jq -r '.rows[0].last_day' - List the tables you can join:
curl -s https://ai.strasmore.com/api/demo/schema | jq -r '.tables[].table'
Then paste the SQL from the panel below into a file named rvol.sql and run the screener: curl -sG https://ai.strasmore.com/api/demo/sql --data-urlencode "sql=$(cat rvol.sql)" | jq -r '(.columns | @tsv), (.rows[] | [.ticker, .rvol_time_adjusted, .rvol_naive, .volume_mm_by_1100] | @tsv)'
That prints a tab separated table: a header row, then the twenty ranked names. The same JSON carries the query the server ran, a row count, and a limits object, so jq '.limits' tells you the ceiling you are working under.
The query has three stages, and they are worth reading in order.
- Stage one builds the universe and the session calendar. It keeps symbols averaging at least 10 million shares a day over the past 45 calendar days with a close of $5 or better, then takes SPY's last 21 dates as the session list, which handles holidays without a hardcoded calendar.
- Stage two measures every symbol on every one of those sessions twice: volume from 9:30 to 11:00, and volume from 9:30 to 4:00. Both come from ET clock arithmetic over the minute bars, never from a hardcoded UTC window.
- Stage three divides. Today's 9:30 to 11:00 volume over the average 9:30 to 11:00 volume of the prior sessions is the time adjusted RVOL. The same numerator over the average full day is the naive one. Sorting by the first produces the ranked list.
| ticker | rvol_time_adjusted | rvol_naive | volume_mm_by_1100 | screen_asof |
|---|---|---|---|---|
| IONQ | 5.23 | 2.16 | 32.25 | Sep 23 |
| UNG | 2.99 | 0.9 | 15.6 | Sep 23 |
| QBTS | 2.41 | 0.96 | 12.56 | Sep 23 |
| WBD | 2.33 | 0.81 | 24.42 | Sep 23 |
| RGTI | 2.14 | 0.88 | 11.62 | Sep 23 |
| CMG | 2.09 | 0.58 | 5.13 | Sep 23 |
| BB | 2.08 | 0.66 | 6.6 | Sep 23 |
| XLF | 2.04 | 0.55 | 17.05 | Sep 23 |
| PLTR | 2 | 0.75 | 15.97 | Sep 23 |
| IEMG | 1.97 | 0.45 | 4.62 | Sep 23 |
| DVN | 1.97 | 0.59 | 4.88 | Sep 23 |
| PSKY | 1.94 | 0.49 | 7.86 | Sep 23 |
| NCLH | 1.92 | 0.51 | 6.78 | Sep 23 |
| RWM | 1.91 | 0.72 | 15.01 | Sep 23 |
| ERY | 1.89 | 0.68 | 9.55 | Sep 23 |
| SOXS | 1.85 | 0.85 | 36.6 | Sep 23 |
| VG | 1.84 | 0.56 | 6.67 | Sep 23 |
| DKNG | 1.79 | 0.41 | 4.07 | Sep 23 |
| META | 1.76 | 0.7 | 11.69 | Sep 23 |
| QID | 1.74 | 0.43 | 14.15 | Sep 23 |
The exact SQL behind every number
WITH
liquid AS (
SELECT ticker
FROM global_markets.stocks_daily_aggs
WHERE date > today() - 45
AND ticker NOT IN ('SPCX')
GROUP BY ticker
HAVING avg(volume) >= 10000000
AND min(close) >= 5
),
sessions AS (
SELECT date
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date > today() - 45
ORDER BY date DESC
LIMIT 21
),
bars AS (
SELECT
ticker,
toDate(et_ts) AS session_date,
toHour(et_ts) * 60 + toMinute(et_ts) AS et_min,
toFloat64(volume) AS vol
FROM
(
SELECT
ticker,
toTimeZone(window_start, 'America/New_York') AS et_ts,
volume
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN (SELECT ticker FROM liquid)
AND window_start >= toDateTime(today() - 45)
)
WHERE session_date IN (SELECT date FROM sessions)
),
totals AS (
SELECT
ticker,
session_date,
sumIf(vol, et_min >= 570 AND et_min < 660) AS vol_by_1100,
sumIf(vol, et_min >= 570 AND et_min < 960) AS vol_day,
maxIf(et_min, et_min < 960) AS last_min
FROM bars
GROUP BY ticker, session_date
),
(
SELECT session_date
FROM totals
GROUP BY session_date
HAVING countIf(last_min >= 955) >= 100
ORDER BY session_date DESC
LIMIT 1
) AS asof
SELECT
ticker,
round(maxIf(vol_by_1100, session_date = asof) / avgIf(vol_by_1100, session_date < asof), 2) AS rvol_time_adjusted,
round(maxIf(vol_by_1100, session_date = asof) / avgIf(vol_day, session_date < asof), 2) AS rvol_naive,
round(maxIf(vol_by_1100, session_date = asof) / 1e6, 2) AS volume_mm_by_1100,
formatDateTime(asof, '%b %e') AS screen_asof
FROM totals
GROUP BY ticker
HAVING countIf(session_date < asof) >= 18
AND maxIf(last_min, session_date = asof) >= 955
AND avgIf(vol_by_1100, session_date < asof) > 0
ORDER BY rvol_time_adjusted DESC
LIMIT 20As of Sep 23, the leader at 11:00 a.m. ET was IONQ at 5.23 times its normal morning volume, on 32.25 million shares. The naive column puts that same tape at 2.16. The last name on the list still reads 1.74 on the corrected measure. Every row sits lower in the naive column by construction, since the numerator is identical and the divisor is larger.
The as of date comes out of the data rather than from a literal: the query takes the most recent session whose tape runs through 15:55 for at least a hundred symbols, and it demands the same of each symbol it ranks. Point that line at today() instead and the same screen runs on the live tape once 11:00 a.m. ET has passed.
Why your numbers will not match Finviz
Four conventions differ across platforms, and each one moves the number.
- The divisor. Same time average against average full day, the difference the panels above measure.
- The lookback. 20 sessions is common, 30 and 65 both appear, and some vendors use a three month average daily volume instead.
- The session scope. This screener counts regular hours only. Daily aggregate volume, the figure most sites quote, also includes premarket and after hours prints, which makes it larger than the 9:30 to 4:00 sum for the same date.
- The tape. Consolidated volume across every venue against volume from the primary listing exchange alone.
There is one more reason a single correction factor cannot be shared across a watchlist.
AMD completes 42.1% of its session by 11:00 a.m. ET, while SPY completes 26.8%. A screen that applies one market wide factor is wrong at both ends of that range, which is the per symbol version of the same trap. Why relative volume differs between platforms works through the rest of the reconciliation, and this week's unusual volume names applies the same measurement over a weekly window. The endpoint and its ceilings are documented in the free stock market data API guide, and the account tier with more room is covered in the free SQL API guide.
Data notes and reproduction
Volume comes from the one minute equity bars, bucketed by ET clock time. The regular session filter is minute 570 through 959 of the ET day, derived from the timestamps rather than assumed, and all stored timestamps are UTC.
Every panel drops incomplete sessions. The screener requires a tape running through 15:55 for at least a hundred symbols before it treats a date as the as of session, and it requires the same of each symbol it ranks. Without that guard, a partly ingested session ranks as a market wide slowdown and the whole list compresses toward 1.0.
The public endpoint's ceilings, as of September 2026: 500 rows, 20 seconds per query, one year of history, no key. The screener as written returns 20 rows and finishes in single digit seconds. Lowering the 10 million share floor widens the universe and lengthens the scan, so a version that trips the 20 second cap wants a higher floor rather than a longer lookback.
The universe excludes symbols that vendor feeds have reused across more than one company, which lets a top 20 slot fall through to a name whose trading history belongs to a single issuer.
Cisco's September 22, 2026 session and the 20 sessions before it are fixed dates in that panel, which keeps its numbers stable on a later run.
FAQ
How do you calculate relative volume intraday?
Divide the volume traded so far in today's regular session by the average volume traded by the same minute of the day over a lookback window, commonly 20 sessions. The denominator has to be measured at the same point on the clock as the numerator. An average of complete sessions in the denominator makes every reading before 4:00 p.m. ET too small.
Why is my relative volume lower than another platform's?
Four settings account for most of the gap: the divisor (same time or full day), the lookback length, whether premarket and after hours prints are counted, and whether volume is consolidated or primary exchange only. Two platforms can each be internally consistent and still disagree by a factor of two.
Can I run a relative volume screener for free?
Yes. The SQL in this post runs against a public endpoint with no key and no signup, with curl and jq as the only tools required. The ceilings are 500 rows, 20 seconds per query, and one year of history as of September 2026.
Does relative volume include premarket volume?
That depends on the convention, which is one of the reasons the same ticker carries different RVOL values across sites. The screener here counts regular hours only, 9:30 a.m. to 4:00 p.m. ET. Published daily volume figures usually include extended hours prints, which makes them larger for the same date.
Every panel here ships with the SQL that produced it. Copy any of it into the endpoint above, or ask the same question in plain English on the Strasmore terminal.