Strasmore Research
Learn Matt ConnorBy Matt Connor

What Is the High-Low Index? Market Breadth

The High-Low Index is the 10-day average of Record High Percent. See the formula worked in round numbers, plus the sample size trap on a quiet tape.

The High-Low Index is a market breadth measure built from two counts: it is the 10-day simple moving average of Record High Percent, and Record High Percent is new 52-week highs divided by the sum of new 52-week highs and new 52-week lows, times 100. A reading above 50 means more stocks are printing new highs than new lows, and a reading below 50 means the reverse. The formula is that short. What follows is the arithmetic worked in round numbers and the sample size flaw that lets a decisive looking reading rest on a handful of stocks.

How do you calculate the High-Low Index?

Two steps, both of which fit on one line.

  1. Record High Percent, for a single session: count the stocks in your universe that closed at a new 52-week high, count the ones that closed at a new 52-week low, divide the highs by the sum of the two, then multiply by 100.
  2. The High-Low Index: average the last 10 daily Record High Percent readings.

Round numbers make it concrete. A tape prints 90 new highs and 30 new lows. Highs plus lows is 120, and 90 divided by 120 is 0.75, so Record High Percent for that session is 75. Now take ten sessions of readings: 75, 70, 65, 60, 55, 50, 45, 40, 35 and 30. They sum to 525, and 525 divided by 10 is 52.5. The High-Low Index reads 52.5 on a day whose own Record High Percent was 75.

That gap is the trade the moving average makes. Ten days of memory smooths a series that can swing from 100 to 0 overnight, at the cost of arriving late. The index answers where breadth has been over two weeks. It never answers what happened this afternoon.

What counts as a new 52-week high?

A stock makes a new 52-week high when its price tops every price it printed in the previous 52 weeks. Data providers split on the basis: some compare intraday highs and lows, others compare closing prices only, and the two produce different counts for the same session. Closing prices are the basis everywhere on this page. New 52-week highs and lows walks through the list itself.

The panel below counts both extremes each session for a fixed basket of 40 large-cap US names, April 1 through July 31, 2026. A basket that size keeps the counts small enough to check by hand, which is what makes the flaw in the formula visible later on.

QueryNew 52-week highs and lows each session: 40 large-cap US names, April to July 2026
The exact SQL behind every number
WITH daily_close AS (
    SELECT ticker,
           toDate(toTimeZone(window_start, 'America/New_York')) AS session_date,
           argMax(close, window_start) AS close_px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker IN ('AAPL','MSFT','NVDA','AMZN','GOOGL','AVGO','JPM','JNJ','XOM','PG',
                     'KO','PEP','WMT','HD','CVX','MRK','PFE','ABBV','CSCO','ORCL',
                     'CRM','ADBE','MCD','NKE','VZ','T','DIS','BA','CAT','GE',
                     'IBM','MMM','UNH','LLY','COST','TGT','SBUX','GS','MS','LIN')
      AND window_start >= toDateTime('2025-03-01 00:00:00')
      AND window_start < toDateTime('2026-08-01 05:00:00')
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
    GROUP BY ticker, session_date
),
extremes AS (
    SELECT cur.ticker AS ticker,
           cur.session_date AS session_date,
           cur.close_px AS close_px,
           max(hist.close_px) AS high_52w,
           min(hist.close_px) AS low_52w
    FROM daily_close AS cur
    INNER JOIN daily_close AS hist ON cur.ticker = hist.ticker
    WHERE cur.session_date >= toDate('2026-04-01')
      AND hist.session_date <= cur.session_date
      AND hist.session_date > cur.session_date - 364
    GROUP BY cur.ticker, cur.session_date, cur.close_px
),
daily AS (
    SELECT session_date,
           countIf(close_px >= high_52w) AS new_highs,
           countIf(close_px <= low_52w) AS new_lows,
           countIf(close_px >= high_52w) + countIf(close_px <= low_52w) AS names_at_extreme
    FROM extremes
    GROUP BY session_date
    HAVING names_at_extreme > 0
)
SELECT session_date AS date,
       formatDateTimeInJodaSyntax(session_date, 'MMM d') AS session_label,
       new_highs,
       new_lows
FROM daily
ORDER BY session_date
Run this yourself

Across 77 sessions the two counts rarely rise together. On Jul 29, the last session in the window, 3 of the 40 names closed at a 52-week high and 0 closed at a 52-week low. On Apr 1, the first session, the split was 1 highs against 1 lows. Every other name in the basket sat somewhere inside its own range and never entered the calculation.

What is a good High-Low Index reading?

The 50 line is the pivot, with highs and lows in balance. The conventional strong marker is 70, where new highs have outnumbered new lows by better than two to one across the whole 10-day average. The conventional weak marker is 30, its mirror image. Between 30 and 70 the index is saying the extremes of the market are mixed, which is where it sits most of the time.

The panel below plots the raw daily Record High Percent against its own 10-day average for the same basket. The lag is visible on the chart rather than described.

QueryRecord High Percent and its 10-day average (the High-Low Index), 40-name basket
The exact SQL behind every number
WITH daily_close AS (
    SELECT ticker,
           toDate(toTimeZone(window_start, 'America/New_York')) AS session_date,
           argMax(close, window_start) AS close_px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker IN ('AAPL','MSFT','NVDA','AMZN','GOOGL','AVGO','JPM','JNJ','XOM','PG',
                     'KO','PEP','WMT','HD','CVX','MRK','PFE','ABBV','CSCO','ORCL',
                     'CRM','ADBE','MCD','NKE','VZ','T','DIS','BA','CAT','GE',
                     'IBM','MMM','UNH','LLY','COST','TGT','SBUX','GS','MS','LIN')
      AND window_start >= toDateTime('2025-03-01 00:00:00')
      AND window_start < toDateTime('2026-08-01 05:00:00')
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
    GROUP BY ticker, session_date
),
extremes AS (
    SELECT cur.ticker AS ticker,
           cur.session_date AS session_date,
           cur.close_px AS close_px,
           max(hist.close_px) AS high_52w,
           min(hist.close_px) AS low_52w
    FROM daily_close AS cur
    INNER JOIN daily_close AS hist ON cur.ticker = hist.ticker
    WHERE cur.session_date >= toDate('2026-04-01')
      AND hist.session_date <= cur.session_date
      AND hist.session_date > cur.session_date - 364
    GROUP BY cur.ticker, cur.session_date, cur.close_px
),
daily AS (
    SELECT session_date,
           countIf(close_px >= high_52w) AS new_highs,
           countIf(close_px >= high_52w) + countIf(close_px <= low_52w) AS names_at_extreme
    FROM extremes
    GROUP BY session_date
    HAVING names_at_extreme > 0
),
rhp AS (
    SELECT session_date,
           round(100 * new_highs / names_at_extreme, 1) AS record_high_pct
    FROM daily
),
smoothed AS (
    SELECT session_date,
           record_high_pct,
           round(avg(record_high_pct) OVER (ORDER BY session_date
                                            ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 1) AS high_low_index_pct,
           count() OVER (ORDER BY session_date
                         ROWS BETWEEN 9 PRECEDING AND CURRENT ROW) AS sessions_in_window
    FROM rhp
)
SELECT session_date AS date,
       formatDateTimeInJodaSyntax(session_date, 'MMM d') AS session_label,
       record_high_pct,
       high_low_index_pct
FROM smoothed
WHERE sessions_in_window = 10
ORDER BY session_date
Run this yourself

The plotted range opens at a Record High Percent of 100 on Apr 15, with the index at 52.5. It closes at 100 on Jul 29, index 64. Read each against the 70 and 30 markers, and read the distance between the two lines as the price of smoothing: on any given day the average is still carrying nine older sessions. The first nine sessions of the window are excluded from the panel, since a 10-day average needs 10 days.

The denominator ignores most of the market

Here is the limitation worth carrying away. The denominator of Record High Percent is new highs plus new lows, and nothing else. A stock resting in the middle of its 52-week range is in neither the numerator nor the denominator. The index cannot distinguish a session where 900 of 3,000 names hit an extreme from one where 9 did, and it prints both with the same confident two-digit number.

Picture a 6,000-name exchange on a slow August session: 40 stocks make new highs, 20 make new lows. Record High Percent is 40 divided by 60, or 66.7, which reads like a broad advance. It was computed from 1% of the listings. The other 5,940 names had no vote.

The 40-name basket puts a countable version of that on screen. Every session in the window is sorted into a bucket by how many names printed any extreme at all.

QueryHow thin the denominator gets: sessions bucketed by names at a 52-week extreme
The exact SQL behind every number
WITH daily_close AS (
    SELECT ticker,
           toDate(toTimeZone(window_start, 'America/New_York')) AS session_date,
           argMax(close, window_start) AS close_px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker IN ('AAPL','MSFT','NVDA','AMZN','GOOGL','AVGO','JPM','JNJ','XOM','PG',
                     'KO','PEP','WMT','HD','CVX','MRK','PFE','ABBV','CSCO','ORCL',
                     'CRM','ADBE','MCD','NKE','VZ','T','DIS','BA','CAT','GE',
                     'IBM','MMM','UNH','LLY','COST','TGT','SBUX','GS','MS','LIN')
      AND window_start >= toDateTime('2025-03-01 00:00:00')
      AND window_start < toDateTime('2026-08-01 05:00:00')
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
    GROUP BY ticker, session_date
),
extremes AS (
    SELECT cur.ticker AS ticker,
           cur.session_date AS session_date,
           cur.close_px AS close_px,
           max(hist.close_px) AS high_52w,
           min(hist.close_px) AS low_52w
    FROM daily_close AS cur
    INNER JOIN daily_close AS hist ON cur.ticker = hist.ticker
    WHERE cur.session_date >= toDate('2026-04-01')
      AND hist.session_date <= cur.session_date
      AND hist.session_date > cur.session_date - 364
    GROUP BY cur.ticker, cur.session_date, cur.close_px
),
daily AS (
    SELECT session_date,
           countIf(close_px >= high_52w) AS new_highs,
           countIf(close_px >= high_52w) + countIf(close_px <= low_52w) AS names_at_extreme
    FROM extremes
    GROUP BY session_date
    HAVING names_at_extreme > 0
)
SELECT multiIf(names_at_extreme <= 3, '1 to 3 names',
               names_at_extreme <= 9, '4 to 9 names',
               '10 or more names') AS sample_size_bucket,
       count() AS readings,
       round(avg(names_at_extreme), 1) AS avg_names_in_denominator,
       round(quantileDeterministic(0.5)(100 * new_highs / names_at_extreme,
                                        cityHash64(session_date)), 1) AS median_record_high_pct,
       round(quantileDeterministic(0.5)(abs(100 * new_highs / names_at_extreme - 50),
                                        cityHash64(session_date)), 1) AS median_distance_from_50
FROM daily
GROUP BY sample_size_bucket
ORDER BY avg_names_in_denominator
Run this yourself

On the 39 sessions in the 1 to 3 names bucket, the whole reading rested on an average of 2 names out of 40. Those sessions carried a median Record High Percent of 100 and sat a median 50 points away from the 50 line. The 10 or more names sessions, 1 of them at an average of 10 names, carried a median 70 and sat 20 points from the middle.

The arithmetic limits what a thin session can even print. With three names at an extreme, the only possible readings are 0, 33.3, 66.7 and 100. With two names, only 0, 50 and 100. A reading of 66.7 built on two highs and one low carries a decimal point its sample cannot support. The habit that fixes this is plain: read the raw counts of highs and lows beside the index, never the index on its own.

Market breadth: High-Low Index vs advance/decline

Both measures ask how many stocks are participating. They ask it over different horizons. Advance/decline breadth counts every name that finished up or down against yesterday. Its denominator is the entire universe, and it describes one day. The High-Low Index counts only names at a 52-week extreme. Its denominator is small, and it describes where prices sit against a full year of history. A tape can be broadly higher on the day while very few names are anywhere near a 52-week high.

The panel below smooths both measures over the same 10 sessions, on the same basket, which puts them on one scale.

QueryTwo breadth measures, same basket: 10-day High-Low Index vs 10-day advancing share
The exact SQL behind every number
WITH daily_close AS (
    SELECT ticker,
           toDate(toTimeZone(window_start, 'America/New_York')) AS session_date,
           argMax(close, window_start) AS close_px
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker IN ('AAPL','MSFT','NVDA','AMZN','GOOGL','AVGO','JPM','JNJ','XOM','PG',
                     'KO','PEP','WMT','HD','CVX','MRK','PFE','ABBV','CSCO','ORCL',
                     'CRM','ADBE','MCD','NKE','VZ','T','DIS','BA','CAT','GE',
                     'IBM','MMM','UNH','LLY','COST','TGT','SBUX','GS','MS','LIN')
      AND window_start >= toDateTime('2025-03-01 00:00:00')
      AND window_start < toDateTime('2026-08-01 05:00:00')
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
    GROUP BY ticker, session_date
),
extremes AS (
    SELECT cur.ticker AS ticker,
           cur.session_date AS session_date,
           cur.close_px AS close_px,
           max(hist.close_px) AS high_52w,
           min(hist.close_px) AS low_52w
    FROM daily_close AS cur
    INNER JOIN daily_close AS hist ON cur.ticker = hist.ticker
    WHERE cur.session_date >= toDate('2026-04-01')
      AND hist.session_date <= cur.session_date
      AND hist.session_date > cur.session_date - 364
    GROUP BY cur.ticker, cur.session_date, cur.close_px
),
daily AS (
    SELECT session_date,
           countIf(close_px >= high_52w) AS new_highs,
           countIf(close_px >= high_52w) + countIf(close_px <= low_52w) AS names_at_extreme
    FROM extremes
    GROUP BY session_date
    HAVING names_at_extreme > 0
),
prior AS (
    SELECT ticker,
           session_date,
           close_px,
           lagInFrame(close_px, 1) OVER (PARTITION BY ticker ORDER BY session_date
                                         ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
    FROM daily_close
),
breadth AS (
    SELECT session_date,
           round(100 * countIf(close_px > prev_close) / count(), 1) AS advancing_pct
    FROM prior
    WHERE session_date >= toDate('2026-04-01')
      AND prev_close > 0
    GROUP BY session_date
    HAVING count() >= 20
),
combined AS (
    SELECT d.session_date AS session_date,
           round(100 * d.new_highs / d.names_at_extreme, 1) AS record_high_pct,
           b.advancing_pct AS advancing_pct
    FROM daily AS d
    INNER JOIN breadth AS b ON d.session_date = b.session_date
),
smoothed AS (
    SELECT session_date,
           round(avg(record_high_pct) OVER (ORDER BY session_date
                                            ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 1) AS high_low_index_pct,
           round(avg(advancing_pct) OVER (ORDER BY session_date
                                          ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 1) AS advancing_pct_10d,
           count() OVER (ORDER BY session_date
                         ROWS BETWEEN 9 PRECEDING AND CURRENT ROW) AS sessions_in_window
    FROM combined
)
SELECT session_date AS date,
       formatDateTimeInJodaSyntax(session_date, 'MMM d') AS session_label,
       high_low_index_pct,
       advancing_pct_10d
FROM smoothed
WHERE sessions_in_window = 10
ORDER BY session_date
Run this yourself

The pair opens the plotted range at 52.5 for the High-Low Index and 52.8 for the 10-day advancing share on Apr 15, and finishes at 64 and 52.5 on Jul 29, across 68 plotted sessions. The advancing share lives in a narrow band. Roughly half a basket closes higher on an ordinary session, and averaging ten of those leaves the line near the middle of the scale. The High-Low Index has no such anchor. When no name in the basket prints a 52-week low for ten sessions running, every daily reading is 100 and the average is 100 with it.

Breadth reads better with participation measures beside it. Relative volume asks whether one name is trading unusually heavily today against its own norm. The week's biggest stock movers shows which names actually travelled, and unusual volume stocks this week shows where the trading went.

High-Low Index FAQ

What does a High-Low Index reading above 70 mean?

It means new 52-week highs outnumbered new 52-week lows by better than two to one across the average of the last ten sessions. That is the conventional strong-tape marker. It says nothing about how many stocks reached an extreme at all, which is the number to check alongside it.

What is the difference between Record High Percent and the High-Low Index?

Record High Percent is the single-session figure: new highs divided by new highs plus new lows, times 100. The High-Low Index is the 10-day simple moving average of that figure. One describes today. The other describes the last two weeks.

Can the High-Low Index be 0 or 100?

Yes. If no stock in the universe makes a new 52-week low for ten straight sessions, every daily reading is 100 and so is the average. When no stock makes either a high or a low, the fraction has a zero denominator and that session has no defined reading.

Is the High-Low Index the same as advance/decline breadth?

No. Advance/decline breadth divides by the whole universe and describes a single day of direction. The High-Low Index divides by the small group of stocks sitting at 52-week extremes and describes position against a year of prices.

How many stocks are usually in the High-Low Index denominator?

On the 40-name basket measured here from April to July 2026, sessions in the 1 to 3 names bucket averaged 2 names in the denominator. Exchange-wide counts run larger in absolute terms while staying a thin slice of all listings, which is why the raw counts belong on the screen next to the index.


Every count on this page is a stored, versioned query over closing prices. Open any panel to read the SQL behind it, or rebuild the same breadth series on a basket of your own on the Strasmore terminal.

#market breadth#52-week highs#indicators#record high percent