5,191 A-Share Stocks, One Number: Breadth Went 13% to 42% in Four Sessions. My Backtest Says Do Not Trade That Pattern.
5,191 A-Share Stocks, One Number: Breadth Went 13% → 42% in Four Sessions. My Backtest Says Don't Trade That Pattern. There is a class of market indicator that is cheap to compute, widely quoted, and almost never count
5,191 A-Share Stocks, One Number: Breadth Went 13% → 42% in Four Sessions. My Backtest Says Don't Trade That Pattern.
There is a class of market indicator that is cheap to compute, widely quoted, and almost never counted correctly: market breadth.
The version I use is boring on purpose. For every listed stock, take the 20-day moving average of closes. Breadth is the share of stocks trading above it. That is it — no weighting, no index constituents, no vendor.
At the close on November 5, 2026 my gauge reads:
| Session | Stocks above MA20 | Universe | Breadth |
|---|---|---|---|
| 2026-10-30 | 692 | 5,192 | 13.33% |
| 2026-11-02 | 1,070 | 5,192 | 20.61% |
| 2026-11-03 | 1,097 | 5,190 | 21.14% |
| 2026-11-04 | 1,534 | 5,192 | 29.55% |
| 2026-11-05 | 2,190 | 5,191 | 42.19% |
493 stocks in one session crossing their own 20-day average is the kind of print that gets reposted with an arrow. So I did the obvious thing and asked my own data what happens next.
The answer was uncomfortable, and it took two corrections to see it.
The data
-
5,202 symbols (all Shanghai + Shenzhen A-shares), 200 front-adjusted daily bars each, pulled from Tencent's public K-line endpoint (
web.ifzq.gtimg.cn/appstock/app/fqkline/get) — free, no key, ~67 seconds for the whole market at 16 threads. - 943,843 stock-days after the MA20 warm-up; 277 sessions from 2025-09-10 to 2026-11-05.
- Forward return = equal-weight close-to-close over the next 5 sessions across all stocks present on that date. Not an index. An index would hide exactly the thing breadth is about.
- Suspended names (volume = 0) dropped from the cross-section each day.
Before trusting any of it I checked the new build against a breadth series I had stored from a September study: 154 overlapping sessions, correlation 0.971, mean absolute difference 3.85pp, worst day 12.8pp. The gap is universe size (that study used ~3,046 names after filters, this one 5,191), and the two agree on direction every single day. Good enough to proceed — with the honest caveat that my breadth number is not anyone else's breadth number, and quoting it as "the market's breadth" would be sloppy.
Finding 1: the bad place is not panic
Day-level, bucketed by breadth, mean forward 5-session return:
| Breadth | Sessions | Mean fwd 5d | t | Win rate | Median |
|---|---|---|---|---|---|
| < 20% | 29 | +0.10% | +0.15 | 51.7% | +0.07% |
| 20–40% | 69 | −1.30% | −3.11 | 37.7% | −1.77% |
| 40–60% | 49 | +0.22% | +0.48 | 51.0% | +0.14% |
| 60–80% | 21 | +0.20% | +0.33 | 42.9% | −0.32% |
| > 80% | 9 | −0.57% | −0.69 | 44.4% | −1.03% |
| all | 177 | −0.43% | — | 44.6% | — |
The relationship is not monotonic, and that is the interesting part. Deep panic breadth (under 20% of stocks above MA20) is not the worst forward environment in this sample — it is flat. The worst environment is 20–40%: a market that is neither washed out nor repaired, where three quarters of names have already lost their 20-day average but the selling has not exhausted itself. Median −1.77%, win rate 37.7%.
Note the baseline: the whole sample averaged −0.43% per 5 sessions, so this window drifted down. Every number above should be read as relative to that, and none of it is a claim about the next five years.
t = −3.11 looks like a finding. It isn't — not yet.
Finding 2: days are not observations
Consecutive sessions in the same bucket are the same regime. Counting 69 "dead zone days" as 69 independent samples is the same error as counting the same customer 69 times because they visited 69 times.
So I counted runs: maximal stretches of at least 3 consecutive sessions inside a bucket, one number per run.
| Breadth | Sessions | Day-level | Runs | Run-mean | Run t | Run win rate |
|---|---|---|---|---|---|---|
| < 20% | 29 | +0.10% | 4 | −1.00% | −0.97 | 50.0% |
| 20–40% | 69 | −1.30% | 9 | −1.89% | −1.89 | 22.2% |
| 40–60% | 49 | +0.22% | 8 | +0.47% | +0.37 | 50.0% |
| 60–80% | 21 | +0.20% | 4 | +0.34% | +0.19 | 50.0% |
| > 80% | 9 | −0.57% | 1 | — | — | — |
Two things changed shape:
- The dead zone survived in direction and got stronger in effect size (−1.89%, 7 of 9 runs negative) but lost its significance (t = −3.11 → −1.89). The original t-stat was partly borrowed from autocorrelation. That is the honest reading: suggestive, unproven.
- The panic bucket flipped sign: +0.10% at day level, −1.00% at run level. Four runs is not enough to establish either direction, which is precisely why I should have printed "4 runs" next to that +0.10% the first time.
The nine dead-zone runs, in full, so you can see how uneven it is:
2026-03-03..03-05 len 3 avg breadth 29.4 fwd5 +3.12%
2026-03-13..03-18 len 4 avg breadth 32.0 fwd5 -6.38%
2026-05-21..05-28 len 6 avg breadth 30.9 fwd5 -2.91%
2026-06-15..07-02 len 13 avg breadth 29.8 fwd5 -1.49%
2026-07-06..07-10 len 5 avg breadth 27.4 fwd5 -3.89%
2026-07-14..07-23 len 8 avg breadth 29.0 fwd5 -1.95%
2026-09-11..09-18 len 6 avg breadth 29.0 fwd5 +2.48%
2026-09-28..10-08 len 4 avg breadth 31.7 fwd5 -2.97%
2026-10-12..10-14 len 3 avg breadth 29.7 fwd5 -3.06%
That is a real pattern with a fat left tail and two of nine runs working anyway. It is a no-trade band, not a short signal.
Finding 3: today's pattern is the one that disappeared entirely
Here is the test I actually wanted: what happens after a 4-session surge of +20pp or more in breadth?
Day-level, it looks great:
- 18 surge days, mean forward 5-session return +1.45%, t = +1.64, win rate 66.7%.
Then cluster them. Consecutive surge days inside a 10-session window are one episode — the same bounce being re-counted while breadth keeps climbing:
| Episode | Surge days | Peak breadth | Mean fwd 5d |
|---|---|---|---|
| 2026-02-27 .. 03-10 | 2 | 65.5% | −3.01% |
| 2026-04-08 .. 04-17 | 6 | 76.4% | +1.41% |
| 2026-07-30 .. 08-07 | 7 | 86.4% | +4.79% |
| 2026-09-21 .. 09-23 | 3 | 56.3% | −3.30% |
Episode-level: n = 4, mean −0.03%, win rate 50%.
Eighteen observations became four, and the edge became zero. The +1.45% / t = 1.64 was manufactured by counting the July–August episode seven times. Any tool that prints a t-stat on overlapping forward windows without clustering is printing a number about its own sample size, not about the market.
Same test, different framing (crossing back above 40% breadth after 10 sessions below 25%): 22 day-events, +1.45%, t = +1.91 — and, once clustered, the same four-ish episodes. Zero information.
Where today sits
Breadth went 13.33% → 42.19% in four sessions: a +28.9pp surge that lands today in the 40–60% bucket. In this sample that bucket is worth +0.22% per 5 sessions at day level and +0.47% over 8 runs — indistinguishable from noise, and the surge that delivered us here is the pattern whose episode-level mean is −0.03%.
So my gauge's honest output tonight is: the worst regime of the last three months just ended, and the event that ended it has no measured edge. That is a much less satisfying sentence than "breadth is confirming the reversal," and it is the only one the data supports.
Rules I now apply to any breadth claim (mine included)
- Cluster before you quote a t-stat. Overlapping forward windows inflate t by roughly √(overlap). Count episodes, and print the episode count next to every mean.
- Report win rate at run level, not day level. A 37.7% day-level win rate and a 22.2% run-level win rate are the same data making two different claims.
- Write the sample's baseline down. All-days mean was −0.43% here. A bucket at +0.22% is not "bullish", it is 0.65pp above a falling baseline.
- A bucket that flips sign under clustering is not a signal, it is an artifact — and finding that out is cheaper than trading it.
- State your universe. 3,046 filtered names and 5,191 raw names give breadth numbers that differ by up to 12.8pp on the same day. Correlation 0.971 is agreement on direction, not agreement on level.
The reproduce-the-number code is short enough to paste:
# breadth for one session, given {symbol: [(date, close, volume), ...]} sorted
above = total = 0
for sym, bars in universe.items():
if len(bars) < 20 or bars[-1][2] == 0: # need MA20, drop suspended
continue
ma20 = sum(b[1] for b in bars[-20:]) / 20
total += 1
above += bars[-1][1] > ma20
breadth = 100 * above / total
Then, per day, the forward number and the run grouping:
fwd5 = mean(100 * (close_at[i+5] / close_at[i] - 1) for every stock present on day i)
runs = group_consecutive_days_by_bucket(days, min_len=3)
t_stat = mean(run_means) / (stdev(run_means) / sqrt(len(run_means)))
The measurement scripts for this (breadth gauge, run-level clustering, and the earlier signal studies) live in github.com/Felixwang007/awesome-content-tools under tools/, each with a selftest subcommand so you can check the counting before you check the market.
The packaged version of the A-share screening logic — three-pillar scoring plus the refusal rules that make it return "no signal" — is on xiaping.coze.com under my handle, free to run.
If you run a breadth gauge of your own: how many runs does your worst bucket contain? That number, not the return, is what tells you whether you have a finding.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.