TL;DR — I rebuilt my Chinese A-share signal study on 3,145 main-board stocks × 622
trading days (2024-01-26 → 2026-09-18) = 904,775 pattern-labelled stock-days, using free
data. Two measurement bugs were inflating the results. After fixing them, none of the six
classic chart patterns beat the equal-weight universe. The only statistically solid
signals left were the two avoidance ones.
This is the follow-up to my earlier volume study
that flipped sign between two consecutive months. Same rule: publish the number even when
it kills the story I wanted to tell.
What I built
Data: daily K-lines for 3,145 Shanghai/Shenzhen main-board names, pulled from Tencent's free
endpoint — no key, no vendor, ~0.2 s per stock:
https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh600000,day,,,640,qfq
Six patterns, each defined by measurable conditions (not by the name a newsletter gives it):
| pattern | condition |
|---|---|
| Breakout+Volume | close > prior 20-day high and volume ratio ≥ 1.5 |
| HighLevelVolumeDrop | in the top 30% of its 20-day range, day ≤ −3%, volume ratio ≥ 1.3 |
| OversoldRebound | MA5 < MA20, 20-day return ≤ −15%, day > +2%, volume ratio ≥ 1.2 |
| LowVolPullback | MA5 > MA20, down day, volume ratio ≤ 0.85 |
| Uptrend | MA5 > MA10 > MA20 and close > MA20 |
| Sideways | 20-day range < 8% and |
Label = forward 5-trading-day return. The rules are ported verbatim from the labelled set I
use to fine-tune my own model, and I checked the port: 5,998 of 6,000 randomly sampled
labelled rows reproduce exactly (99.97%), so the labels here and the labels in the model's
training data agree.
Bug #1 — the ragged panel invents market days
Concatenating one file per stock gives 6,487 distinct dates. Only 622 of them are real
market days; the other 5,870 are phantom "sessions" where one or two stale or delisted
files traded (median 4 rows). Averaging over them moved conclusions a lot:
pattern clustered (622 real days) over all dates incl. phantom
Uptrend +0.191% t=+1.47 +0.526% t=+5.66
LowVolPullback +0.317% t=+2.29 +0.546% t=+5.48
Sideways +0.322% t=+3.11 +0.020% t=+0.28
An "uptrend continuation edge" with t=+5.7 turned into t=+1.5 — i.e. nothing — purely from
stale single-name dates. Guard: a date only counts if ≥500 universe members have a defined
20-day MA that day.
Bug #2 — one session is not 200 independent samples
This is the bigger one. Stock-days inside a session are highly correlated: a market-wide
bounce lifts hundreds of "oversold rebound" names at once.
Raw row-level average for oversold-rebound setups on days when >60% of the market was above
its 20-day MA: +11.35%, 67.8% win rate, 374 rows. Same data, one observation per day
instead of one per row: +0.65%. Two sessions carried the whole thing — 2026-07-31
(222 names, +14.25% average) and 2026-08-04 (34 names).
The fix is five lines:
per_day = {}
for r in rows:
per_day.setdefault(r["date"], []).append(r["fwd5"])
day_means = [statistics.fmean(v) for v in per_day.values()]
mean = statistics.fmean(day_means)
t = mean / (statistics.stdev(day_means) / len(day_means) ** 0.5) # n = DAYS, not rows
What survived both fixes
Mean of per-day means, forward 5 days, 2024-01 → 2026-09:
| pattern | days | rows | fwd 5d | t | naive row-level mean |
|---|---|---|---|---|---|
| Sideways | 603 | 175,338 | +0.322% | +3.11 | +0.425% |
| LowVolPullback | 615 | 231,310 | +0.317% | +2.29 | +0.526% |
| Uptrend | 617 | 401,002 | +0.191% | +1.47 | +0.362% |
| OversoldRebound | 462 | 5,934 | +0.033% | +0.10 | +1.547% |
| Breakout+Volume | 617 | 59,352 | −0.432% | −2.84 | −0.289% |
| HighLevelVolumeDrop | 608 | 7,151 | −1.557% | −6.30 | −1.152% |
Note the last column: the oversold-rebound row-level mean is 47× its clustered value.
That 47× is the most common backtest lie on the internet.
The test that killed the bullish patterns
A positive raw return does not mean the pattern works — it may just mean the market went up.
So subtract the same-day equal-weight universe return from every row:
| pattern | excess fwd 5d | t |
|---|---|---|
| HighLevelVolumeDrop | −1.958% | −9.84 |
| Breakout+Volume | −0.843% | −8.54 |
| OversoldRebound | −0.361% | −1.39 |
| Uptrend | −0.220% | −3.95 |
| LowVolPullback | −0.117% | −2.18 |
| Sideways | −0.065% | −0.94 |
Not one bullish pattern beat just holding the basket. Every positive number in the
previous table was market beta — including "buy the uptrend" and "buy the pullback". What
survives is only what you should avoid: a high-level drop on heavy volume, and chasing a
volume breakout.
The one filter that did move the needle: breadth
Because beta is the whole game, the market's own breadth is the useful variable. I built a
gauge: share of the universe trading above its own 20-day MA, once per day, from all
3,145 names.
Using that as a regime filter on the equal-weight universe (next 5 trading days):
| breadth bucket | days | next-5d equal-weight | t | up-days |
|---|---|---|---|---|
| <20% | 68 | +0.80% | +2.22 | 63.2% |
| 20-40% | 163 | −0.50% | −1.87 | 46.6% |
| 40-60% | 160 | +0.93% | +3.51 | 60.6% |
| 60-80% | 143 | +0.36% | +1.86 | 58.7% |
| >80% | 83 | +0.97% | +2.03 | 59.0% |
Both tails are positive; the dead zone is 20-40% — and that is exactly where the market
sat at the end of this sample (2026-09-18: 32.63% above MA20, 28th percentile of 622
days).
Inside that band, no pattern has positive expectancy:
breadth 20-40%: high-level volume drop -2.92% (t-5.5)
breakout + volume -1.35% (t-4.7)
oversold rebound -1.81% (t-3.4)
low-volume pullback -0.86% (t-3.0)
uptrend -0.80% (t-3.3)
breadth 40-60%: low-volume pullback +0.96% (t+3.5)
uptrend +0.73% (t+2.8)
sideways +0.76% (t+3.6)
The year split (2024 / 2025 / 2026) keeps the sign of the 40-60% bucket in all three years
(+1.1 / +1.1 / +0.5), so that middle band is not one lucky stretch. The tails move around.
How I use this now
- Benchmark against the day's equal-weight return, not against zero. Half the "edges" in retail technical analysis are just exposure.
- Cluster before you compute a t-stat. If your study has 400 rows but 8 effective days, you have 8 observations.
- Guard the panel. A minimum-names-per-day rule catches stale files before they become "evidence".
- Sell signals are more robust than buy signals here. Two of two avoidance patterns survived with |t| > 6; zero of four bullish ones did.
Caveats
Main board only (no ChiNext / STAR / BSE), 2024–2026 sample, no transaction costs, no T+1
fill modelling, overlapping 5-day windows (so the t-stats are optimistic, not
conservative), and I inspected dozens of signal × regime cells — at that count some t≈2
values are noise. Treat this as a measurement lesson, not a trading system.
Reproduce it
Everything is one script against free data — no API key:
# one JSON per stock: [{date,open,high,low,close,volume}, ...]
python breadth_gauge.py path/to/history_ext
→ github.com/Felixwang007/a-share-signal-lab — breadth_gauge.py (builds the gauge and
every number above), breadth_gauge.json (622 days of breadth), breadth_stats.json (the
tables), plus the earlier volume study.
If you want this kind of data-audit tooling packaged for an AI coding agent to run for you,
I publish skills for that on 虾评 — but the honest version of the
finding lives in the repo above, where you can recompute it yourself.
Top comments (0)