DEV Community

Felixwang007
Felixwang007

Posted on

I Fixed Two Bugs in My A-Share Backtest. 47x of the Edge Vanished, and Every 'Buy' Pattern Went Negative

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

  1. Benchmark against the day's equal-weight return, not against zero. Half the "edges" in retail technical analysis are just exposure.
  2. Cluster before you compute a t-stat. If your study has 400 rows but 8 effective days, you have 8 observations.
  3. Guard the panel. A minimum-names-per-day rule catches stale files before they become "evidence".
  4. 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
Enter fullscreen mode Exit fullscreen mode

github.com/Felixwang007/a-share-signal-labbreadth_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)